deepdebug-local-agent 1.9.0 → 1.10.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 CHANGED
@@ -15,7 +15,7 @@ Claude Code also reads `CLAUDE.md`; the same rules apply to every agent.
15
15
  ## Always
16
16
 
17
17
  - PRs target `main` (publishes the npm package); Cloud Run deploy is manual.
18
- - Runs inside customer infrastructure: argument-array process execution, paths confined to the workspace, no LLM calls, no secrets in logs.
18
+ - Runs inside customer infrastructure: argument-array process execution, paths confined to the workspace, no LLM calls except the opt-in F3-10 client (exception approved by Edu 2026-11-03; `LOCAL_AGENT_LLM_CLIENT`: AI-gateway virtual key only, DLP enforce, see `docs/la-jobs-and-llm.md`), no secrets in logs.
19
19
  - Never print, log or commit secrets. Provider keys exist only in vh-litellm.
20
20
  - Verify on the deployed artefact (curl, revision logs, bundle grep), not only on the diff.
21
21
  - Small PRs with one intent each. Open PRs; a human merges them.
package/CLAUDE.md CHANGED
@@ -7,7 +7,7 @@
7
7
  layers, simplicity, testing, review checklist, `node.md`).
8
8
  3. `README.md`: features, legacy connectors, configuration.
9
9
 
10
- Non-negotiables: no LLM calls and no provider keys here (the one exception, approved by Edu 2026-11-03, is the VIRTUAL key of the AI gateway delivered by `config.update`, F3-09, `client` tenants only: flag `LOCAL_AGENT_LLM_KEY`, memory by default and a 0600 file in a 0700 dir only with `LOCAL_AGENT_LLM_KEY_FILE`, never in logs or acks; there is still no LLM client); exec with argument arrays only;
10
+ Non-negotiables: no LLM calls and no provider keys here (the one exception, approved by Edu 2026-11-03, is the VIRTUAL key of the AI gateway delivered by `config.update`, F3-09, `client` tenants only: flag `LOCAL_AGENT_LLM_KEY`, memory by default and a 0600 file in a 0700 dir only with `LOCAL_AGENT_LLM_KEY_FILE`, never in logs or acks. F3-10 adds the ONLY LLM client (exception approved by Edu 2026-11-03; the virtual key lives only in memory or a 0600 file and never in logs), flag `LOCAL_AGENT_LLM_CLIENT` (default off; needs v2, the key holder and `LOCAL_AGENT_DLP_MODE=enforce`; every prompt is DLP-masked before it leaves; it calls only the AI gateway endpoint the control plane delivered with the key; no prompt, answer or key in logs, audit or frames; see `docs/la-jobs-and-llm.md`); exec with argument arrays only;
11
11
  paths confined to the workspace; never log secrets or raw customer log payloads beyond
12
12
  what an incident needs; PRs target `main` (merging publishes the npm package).
13
13
  Personal, uncommitted notes go in `CLAUDE.local.md` (gitignored).
@@ -0,0 +1,56 @@
1
+ # LLM client and job.assign receiver (F3-10, flags default false)
2
+
3
+ Design: vault `10 Library/Plans/Fase 3 Desenho Tecnico 2026-10-28.md` (sec. 4.2, 5.1, 5.2), ADR-0017. Contract: `src/la-protocol-v2/contract` (`job.assign`, `job.cancel`,
4
+ `job.event`, `job.result`, `config.update.llmKey`). The only contract change F3-10 brought is the optional closed object `job.assign.payload.options` and the capability `job.options` (see below; the copy is
5
+ the gateway's, synced by `scripts/sync-la-protocol-v2-contract.mjs`). The gateway side of the dispatch (sending `job.assign`) is vh-api-gateway `deepdebug.jobs.dispatch-to-la.enabled`.
6
+
7
+ ## Flags (all default false; the first three are needed for anything below)
8
+ `LOCAL_AGENT_PROTOCOL_V2=true`, `LOCAL_AGENT_DLP_MODE=enforce` (no opt-out, read at every call), `LOCAL_AGENT_LLM_KEY=true` (F3-09 holder), then
9
+ `LOCAL_AGENT_LLM_CLIENT=true` and/or `LOCAL_AGENT_JOBS=true` (+ a runner). `runtime.js` logs an ERROR and leaves the feature off when a prerequisite is missing. Everything off = register/hello/acks exactly as before.
10
+
11
+ ## LLM client (`src/la-protocol-v2/llm/`, `link.llm.client.chat()`)
12
+ - OpenAI-compatible `POST <endpoint of the key><LOCAL_AGENT_LLM_PATH>` through `link.llm.request` (the holder: https only, `Authorization` set by the holder, redirects refused). The key is only a header.
13
+ - Order: shape -> DLP enforce -> key held and alias in the key's models -> mask EVERY message (blocked class / DLP failure / oversize = refused, nothing sent) -> size and token budget -> concurrency slot -> call.
14
+ - Limits: timeout per attempt, stream idle timeout, total deadline, <= 5 retries (network error, 408, 429, 5xx; `Retry-After` honoured, capped 10 s; never a 4xx, never after a delta was delivered), prompt bytes, output
15
+ tokens, response bytes, concurrent calls (`busy`). Streaming (`stream:true`, `onDelta`) with `stream_options.include_usage`.
16
+ - Tags: request `metadata.tags` = `vh-tenant:<id>`, `vh-la:<agent>`, `vh-job:<id>`, `vh-agent:<runner>` (ids only; `LOCAL_AGENT_LLM_TAGS=false` to omit). The key carries the same tenant/LA tags at the proxy. Whether LiteLLM merges request tags with key tags is NOT verified (fake server only).
17
+ - Errors are codes only (`LlmClientError.code`, closed list in `client.js`); the body of an error response is never read. Usage comes from `usage`; without it the tokens are estimated (bytes/3) so a budget still counts. Cost from `x-litellm-response-cost` when present.
18
+ - Local audit (ring, optional `LOCAL_AGENT_LLM_AUDIT_FILE`): alias, byte and token counts, per-rule redaction counts, short hash of the job id. Never a prompt, answer or key.
19
+
20
+ ## job.assign receiver (`src/la-protocol-v2/jobs/`, `link.jobs`)
21
+ - The session has already checked schema and Ed25519 signature. The handler answers the ack: `accepted`; `rejected protocol_unsupported` (no runner, DLP not enforce, kind not served); `rejected unauthorized`
22
+ (workspace not served here / of another tenant); `rejected rate_limited` (concurrency + queue, memory reservation, RSS brake); `rejected schema_violation` (`options` malformed, second line behind the session's schema check).
23
+ A known job id (running, queued, finished) is `accepted` and not run again. Admission is synchronous (check and reservation in one step), so a burst of `job.assign` can never exceed concurrency + queue.
24
+ - Budget (`tokens`, `seconds`) is clamped to `LOCAL_AGENT_JOBS_MAX_TOKENS/_SECONDS`. Failures after the ack are `job.event failed` + `job.result failed` with an `errorCode` (no key / key mismatch = `llm_error`).
25
+ - `job.event`: progress is coalesced (<= 1 per `LOCAL_AGENT_JOBS_EVENT_MIN_MS`, last state wins, dropped when the send queue is full); the terminal `job.event` + `job.result` are retried and never dropped for backpressure.
26
+ Payloads are built from a whitelist (ids, enums, counters, cost, duration): no model name, prompt, answer, path or error text. `job.cancel` aborts the job's `AbortSignal`, calls `runner.cancel(jobId)` and abandons a runner
27
+ that has not stopped after `LOCAL_AGENT_JOBS_CANCEL_GRACE_MS`. Unknown/finished ids are acked `accepted` (no oracle) and cancelling twice is harmless. A `job.cancel` for an id never seen is remembered (256, bounded): a
28
+ `job.assign` that arrives later for it (an orphan the control plane already gave up) is acked `accepted` and NOT run. A cancel whose `jobId` is not a UUID is refused (`schema_violation`: by the session, which closes that
29
+ connection like any closed-schema breach; `rejected schema_violation` by the handler if called without it), with no lookup and no effect. After a cancel, a timeout or an abandonment `ctx.readFile` reads nothing (`status:"cancelled"`)
30
+ and the signal handed to the reader is aborted, so a read in flight releases its descriptor and its buffer is dropped.
31
+ - Memory: each admitted job reserves its inline bytes + `LOCAL_AGENT_JOBS_MEM_PER_JOB_BYTES` against `LOCAL_AGENT_JOBS_MAX_MEMORY_BYTES`; `LOCAL_AGENT_JOBS_MAX_RSS_BYTES` (>0) refuses new jobs while the process RSS is above it. Accounting plus a brake, not a hard cap: a runner is code in this process.
32
+ The inline inputs (transit content) are dropped when the job ends.
33
+ - Capability `job.assign` (covers `job.cancel`) is announced only when the receiver is installed (flag + runner); the contract README (section (f)) documents it. Capability `job.options` is announced together with it
34
+ only when the runner declared `supportsOptions: true` (the smoke runner does; an injected one must say so).
35
+
36
+ ## `job.assign` options (contract 2.1, capability `job.options`)
37
+ Optional, closed: `ticketKey`, `branchName`, `createBranch`, `createPullRequest`, `dryRun`, `maxIterations` (1..100). The runner reads them as `job.options` (`jobs/options.js`): frozen, every field present
38
+ (defaults: branch and PR on, no dry run, `maxIterations` = `LOCAL_AGENT_JOBS_MAX_ITERATIONS`, default 25, 1..100; a job can only LOWER it) plus `effects` = what the runner may do outside: `dryRun` -> no branch, no push, no PR;
39
+ `createBranch:false` -> work on `repoRef.branch`, never push to it, no PR; `createPullRequest:false` -> no PR. A runner without `supportsOptions` gets a job that carries options refused (`protocol_unsupported`), so
40
+ `dryRun` is never ignored silently. Option values are not content, but they are not logged either (the audit lists the key names). The smoke runner never touches Git, so it honours them trivially; a dry run still asks its question.
41
+
42
+ ## Runner interface (F3-08c plugs the packaged agent loop here)
43
+ ```
44
+ Runner { name, kinds? (default fix|implement|quality), needsLlm? (default true), supportsOptions? (default false), start(job, ctx): Promise<Outcome>, cancel(jobId) }
45
+ job = { jobId, kind, workspaceId, incidentRef?, repoRef, policy, budget(clamped), llm{alias,keyRef}, inputs[], options{...} } // frozen
46
+ ctx = { signal, workspace{id}, llm.chat(opts), readFile(ref,{maxBytes}), progress({phase,iteration,filesRead,filesChanged}), budget() }
47
+ Outcome = { outcome: pr_opened|no_change|failed|cancelled, errorCode?, prUrl?, branch?, commitSha?, filesChangedCount?, diffSha256?, tests?, answer? }
48
+ ```
49
+ `ctx.llm.chat` fills in the job's alias, signal, tags and remaining budget and counts tokens/cost into the job. `ctx.readFile` is the content handlers' gate (opaque id minted by this agent for this
50
+ workspace, deny list, root/symlink checks), so it needs `LOCAL_AGENT_CONTENT_HANDLERS=true`. `answer` is client content: it goes to the local `onResult` hook only, never on the wire. Install a runner with
51
+ `buildV2Link({ jobRunner })` (announces the capability) or `link.jobs.setRunner()`.
52
+ Reference runner `LOCAL_AGENT_JOBS_RUNNER=smoke`: reads the first `inputs[].ref` file, asks one fixed question, outcome `no_change`. For QA and tests, not for production.
53
+
54
+ ## Git credentials
55
+ The control plane never sends a Git token: `job.assign` is a closed schema with no field for one (a test sends one and gets `schema_violation`). A runner that opens a PR uses what the machine already has: the
56
+ user's git credential helper, ssh-agent, or variables of the agent's own process environment. The `repoRef` only names provider/org/repo/branch.
@@ -149,11 +149,11 @@ Needs `LOCAL_AGENT_PROTOCOL_V2=true`. Details, limits and hooks: `src/incident-d
149
149
  ## Contract 2.1 consumers (F3-09 / F3-12 / F3-11b, flags default false)
150
150
 
151
151
  The contract copy is revision 2.1 (`src/la-protocol-v2/contract/CONTRACT_SOURCE.json` names the gateway commit and branch). With every flag off the agent announces exactly what it
152
- announced before (`protocol.v2 v1.commands chunk.base64`); `contract.v2.1` is announced only when one of the three features below is on.
152
+ announced before (`protocol.v2 v1.commands chunk.base64`); `contract.v2.1` is announced only when one of the three features below is on. `job.assign` / `job.options` (F3-10) are plain capabilities, announced with `LOCAL_AGENT_JOBS` (see `docs/la-jobs-and-llm.md`).
153
153
  - **LLM key** (`LOCAL_AGENT_LLM_KEY`, capability `llm.key`; `src/la-protocol-v2/llm-key/`): `config.update.llmKey` `set` / `revoke`. The virtual key of the AI gateway is held in a
154
154
  private field (memory) or, with `LOCAL_AGENT_LLM_KEY_FILE`, in a 0600 file outside every workspace; it is never in an ack, a log, the audit or a frame. A key whose id is not the
155
- sha256 of the key, or that has expired, is `rejected schema_violation`. There is NO LLM client in the agent: `link.llm.request(path, init)` is the hook (https endpoint of the control
156
- plane, Authorization set by the holder, redirects refused) and nothing calls it yet. Memory by default; the file only with `LOCAL_AGENT_LLM_KEY_FILE` (file 0600, directory 0700;
155
+ sha256 of the key, or that has expired, is `rejected schema_violation`. The only caller of `link.llm.request(path, init)` (https endpoint of the control
156
+ plane, Authorization set by the holder, redirects refused) is the F3-10 LLM client (`LOCAL_AGENT_LLM_CLIENT`, see `docs/la-jobs-and-llm.md`); without that flag nothing calls it. Memory by default; the file only with `LOCAL_AGENT_LLM_KEY_FILE` (file 0600, directory 0700;
157
157
  exception to "no keys here" approved by Edu 2026-11-03, recorded in `CLAUDE.md`). `llm.key.held` (contract 2.1 (e)): at EVERY connect the agent adds this capability to `register` AND `hello`
158
158
  only while the holder has a valid, unexpired key (never without `llm.key`); a revoked or expired key stops being announced on the next connection, so the control plane delivers a new one.
159
159
  - **Webhooks** (`LOCAL_AGENT_WEBHOOK_FORWARD`, capabilities `webhook.forward` + `webhook.chunked`; `src/la-protocol-v2/webhooks/`): one frame (<= 9216 bytes) or chunks (16 KiB, <= 64, <= 1 MiB)
package/env.example CHANGED
@@ -97,4 +97,10 @@ LOG_LEVEL=info
97
97
  # LOCAL_AGENT_INCIDENT_DETECTOR=true (the local consumer). Tighten only: LOCAL_AGENT_WEBHOOK_MAX_BYTES, _MAX_OPEN, _MAX_BUFFERED_BYTES, _ASSEMBLY_MS, _MAX_ENTRIES. LOCAL_AGENT_WEBHOOK_DELIVERY_MS (default 10000, max 14000: how long the final ack waits for the local delivery), _DEDUPE_MS (600000) and _DEDUPE_MAX (1024): resends of a delivered webhookId are acked without reprocessing
98
98
  # LOCAL_AGENT_LLM_KEY=true accepts the virtual key of the AI gateway (config.update llmKey): in memory only, never logged or returned in an ack. Needs LOCAL_AGENT_PROTOCOL_V2=true
99
99
  # LOCAL_AGENT_LLM_KEY_FILE=/var/lib/deepdebug/llm-key.json optional: keep the key across restarts (mode 0600, directory 0700, MUST be outside every workspace)
100
+ # LOCAL_AGENT_LLM_CLIENT=true F3-10: chat/completions to the AI gateway named by the key (OpenAI-compatible, optional streaming). Needs LOCAL_AGENT_PROTOCOL_V2=true, LOCAL_AGENT_LLM_KEY=true
101
+ # AND LOCAL_AGENT_DLP_MODE=enforce (every prompt is masked before it leaves). Never logs a prompt, an answer or the key. Tighten/tune: LOCAL_AGENT_LLM_PATH (default
102
+ # /v1/chat/completions), _TIMEOUT_MS, _STREAM_IDLE_MS, _TOTAL_MS, _MAX_RETRIES (2), _MAX_PROMPT_BYTES, _MAX_OUTPUT_TOKENS, _MAX_RESPONSE_BYTES, _MAX_CONCURRENT, _TAGS, _AUDIT_FILE
103
+ # LOCAL_AGENT_JOBS=true F3-10: receives job.assign / job.cancel, runs the job with a runner, answers with job.event / job.result (metadata only). Needs v2, DLP enforce and a runner:
104
+ # LOCAL_AGENT_JOBS_RUNNER=smoke the reference runner (one fixed question about one allowed file; needs the LLM client). Limits: LOCAL_AGENT_JOBS_MAX_CONCURRENT (1), _MAX_QUEUED (4), _MAX_TOKENS,
105
+ # _MAX_SECONDS, _MAX_ITERATIONS (25; caps job.assign options.maxIterations), _MEM_PER_JOB_BYTES, _MAX_MEMORY_BYTES, _MAX_RSS_BYTES (0 = off), _EVENT_MIN_MS, _CANCEL_GRACE_MS, _AUDIT_FILE. Git credentials stay the machine's own (never sent by the control plane)
100
106
  # LOCAL_AGENT_CONTENT_DENY_GLOBS=a/,*.x extra deny globs (they add to the built-in list: .env*, keys, credentials files, secrets/ ...)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepdebug-local-agent",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "DeepDebug Local Agent - AI-powered code debugging assistant",
5
5
  "private": false,
6
6
  "type": "module",
@@ -12,7 +12,8 @@ import { dlpConfigFromEnv } from "../../dlp/index.js";
12
12
  import { ContentRefRegistry } from "./ref-registry.js";
13
13
  import { createContentHandler, SERVED_KINDS } from "./handler.js";
14
14
  import { createContentIndexer } from "./content-index.js";
15
- import { sizeInsideSync } from "./path-guard.js";
15
+ import { sizeInsideSync, normalizeRel, resolveInside, readRegular } from "./path-guard.js";
16
+ import { decodeText } from "./egress.js";
16
17
 
17
18
  export const CONTENT_CAPABILITIES = Object.freeze(SERVED_KINDS.map((k) => `content.${k}`));
18
19
 
@@ -58,7 +59,32 @@ export function installContentHandlers({ link, tenantId, wsManager, getWorkspace
58
59
  const closeLink = link.close.bind(link);
59
60
  link.close = () => { handler.cancelAll(); indexer.close(); registry.clear(); closeLink(); };
60
61
 
62
+ /**
63
+ * F3-10: the job runners read client files through the SAME gate as content.request kind `file`: an opaque id minted by this agent for this
64
+ * workspace (never a path), the deny list, the root and symlink checks, the size cap. Returns the UTF-8 text (a runner that sends it anywhere
65
+ * does so through the DLP: the LLM client masks every prompt) or a status: not_found | forbidden | too_large.
66
+ * @returns {Promise<{ok:true, text:string} | {ok:false, status:string}>}
67
+ */
68
+ const readAllowedFile = async ({ workspaceId, ref, maxBytes, signal }) => {
69
+ try {
70
+ const ws = resolveWorkspace(workspaceId);
71
+ if (!ws) return { ok: false, status: "not_found" };
72
+ const target = registry.resolve(ref, { workspaceId, root: ws.root, kind: "file" });
73
+ const norm = target && normalizeRel(target.rel);
74
+ if (!norm) return { ok: false, status: "not_found" };
75
+ const resolved = await resolveInside(ws.root, norm, config.extraDenyGlobs, { allowHardlinks: config.allowHardlinks });
76
+ if (!resolved.ok) return { ok: false, status: resolved.status };
77
+ const read = await readRegular(resolved, Math.min(Number.isSafeInteger(maxBytes) && maxBytes > 0 ? maxBytes : config.maxBytes, config.maxBytes), signal || new AbortController().signal, { allowHardlinks: config.allowHardlinks });
78
+ if (!read.ok) return { ok: false, status: read.status };
79
+ const text = decodeText(read.buffer);
80
+ return text === null ? { ok: false, status: "forbidden" } : { ok: true, text };
81
+ } catch {
82
+ return { ok: false, status: "not_found" }; // never an exception with a path in it
83
+ }
84
+ };
85
+
61
86
  link.content = {
87
+ readAllowedFile,
62
88
  registry,
63
89
  handler,
64
90
  indexer,
@@ -2,10 +2,10 @@
2
2
  "repo": "VISIONHAMMER/vh-api-gateway",
3
3
  "path": "docs/contracts/la-protocol-v2",
4
4
  "branch": "release/qa",
5
- "commit": "c0d6b44a89d2d18a2627493403c06b9caa7d1a96",
5
+ "commit": "b9621b2a30bb38521b9caab24a79e8bdf536a2d3",
6
6
  "note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
7
7
  "files": {
8
- "README.md": "e247945f859f777a705c5baebecf2a216462a7c2fe1c30598099c96c88cd432b",
8
+ "README.md": "9a6f2f3adc098aba38c2da793c1c3db9bb67d1ec5343f74533dff667c2c1e0fb",
9
9
  "ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
10
10
  "alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
11
11
  "approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
@@ -22,7 +22,7 @@
22
22
  "hello.schema.json": "a90a6bf0e9a8a20e4c6edcf2137931cf044846e7924977228275ef648a48934f",
23
23
  "incident.candidate.schema.json": "ffec36aca6916079debc2c6060d093819ea3ea660b57c0c92ac3b541a0e99000",
24
24
  "index.json": "de79c065217e1fa8430c4266ec19fd57df35fc4182eb7c7246b6f1ea03d2630c",
25
- "job.assign.schema.json": "fdebb4ae98bf38a2c723281c1cf9477f71d877171df9a55b0baeb0b65244f60c",
25
+ "job.assign.schema.json": "c61bf42bfd94003ca373f94f2f7adb93026b1b96079beab97662497c629cbec1",
26
26
  "job.cancel.schema.json": "40ef64373283e17306d857c9a40081689786015294b0b88ab8f68f063bd2cfb8",
27
27
  "job.event.schema.json": "493d08f9db4f7ffd4a97403ee33ca4a73a65dc8f774d8f18ef0a137fdb5fc470",
28
28
  "job.result.schema.json": "9cbc61211482557283b8f4df44e79011ead1abbacdb2d986138199c747edc043",
@@ -46,7 +46,7 @@
46
46
  "vectors/heartbeat.vectors.json": "cfd2e048139c67aa925f1fa94c05326e75248839645c1a1e4d6d3bb60cafa624",
47
47
  "vectors/hello.vectors.json": "7a50c4e54e6a4ef1227fd7485071bca8ec7b797c3b2907fddda3d8836c3feab6",
48
48
  "vectors/incident.candidate.vectors.json": "a78eb4b103f411f1ec64d9eeb4c39623851e710ac0cc20b4d78b2739b7ff78d5",
49
- "vectors/job.assign.vectors.json": "e510c2cc6d17238d477429c255d828c0339b504e39a3a50d4b6ca980e262d9ef",
49
+ "vectors/job.assign.vectors.json": "0358a8e3bd94a332ef922abd80faf6e07bb8dbec0436cb463a4826a47e4d65e2",
50
50
  "vectors/job.cancel.vectors.json": "0fc9f99c8662435e9425389068adf418273489dc73928a21eae1b283256b9bc5",
51
51
  "vectors/job.event.vectors.json": "03a2a2435918ba561821916bf4e435f2780210900139ecf68091acac6abf200c",
52
52
  "vectors/job.result.vectors.json": "05995fbe65e5357705d75e9b660b517f54c6902069d36b62190b1b79f283e7a5",
@@ -5,7 +5,7 @@ sides. Design: vault `10 Library/Plans/Fase 3 Desenho Tecnico 2026-10-28.md` sec
5
5
  implements it behind `deepdebug.agent-ws.protocol-v2.enabled` (default off, F3-05a); the LA side (F3-05b) does not
6
6
  exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json` from this folder as classpath
7
7
  `la-protocol-v2/` (pom resource; both Dockerfiles copy this folder), so this folder stays the only source.
8
- **Revision 2.1** (`index.json` `revision`, Fase 3, last section): optional LLM key delivery, chunked webhooks, size/model in `incident.candidate`, `ready.capabilities`.
8
+ **Revision 2.1** (`index.json` `revision`, Fase 3, last section): optional LLM key delivery, chunked webhooks, size/model in `incident.candidate`, `ready.capabilities`, `job.assign.options` and the capabilities `job.assign` / `job.options`.
9
9
 
10
10
  ## Files
11
11
  - `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (22):
@@ -282,6 +282,30 @@ delivers again when the key is close to expiry (< 14 days), so the LA would stay
282
282
  - **Compatible.** Optional and additive: a register/hello without it is valid (2.0, or 2.1 after a restart), and one with it is valid under 2.1 only
283
283
  together with `llm.key`. No frame type, no new field and no new limit. The CP side of this rule is gateway runtime code (a later task, not this contract).
284
284
 
285
+ ### (f) Jobs: `job.assign`, `job.cancel`, `job.event`, `job.result` and the capabilities `job.assign` and `job.options` (F3-10)
286
+ The four job frames exist since 2.0 (their schemas did not change, except `options` below). What F3-10 adds is the negotiation and one optional field.
287
+ - **Capability `job.assign`.** An LA that runs jobs announces `job.assign` in `register.payload.capabilities` and in `hello.payload.capabilities`. It covers BOTH
288
+ `job.assign` and `job.cancel` (one name, because an LA that can take a job can stop it): the CP sends neither to a session that did not announce it, and a CP
289
+ that has no agent with it answers its caller `503 AGENT_UNSUPPORTED`. A LA announces it only when it has a runner to run the job (no runner, no capability).
290
+ An LA that announced it but still answers `job.assign` with `rejected protocol_unsupported` (its jobs feature is off) did NOT take the job: the CP may offer
291
+ it to another agent of the tenant.
292
+ - **Capability `job.options`.** An LA that understands `job.assign.payload.options` announces `job.options` (it implies `job.assign`). The CP sends `options` ONLY to
293
+ such a session. Both names match the `capability` pattern, so no enum changes.
294
+ - **`job.assign.payload.options` (optional, closed object, `minProperties` 1).** How to run the job; absent means the LA's own defaults, exactly as in 2.0.
295
+ Fields (all optional): `ticketKey` (a `shortId`: the ticket of the request), `branchName` (<= 255, `^(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*[./]$)(?!.*\.lock(/|$))[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$`:
296
+ no `..`, no `//`, no component starting with `.`, no trailing `.` or `/`, no `.lock` suffix, no space or shell character, no leading `-`), `createBranch` (bool),
297
+ `createPullRequest` (bool), `dryRun` (bool), `maxIterations` (integer 1..100). None of them is content (an id, a ref name, flags, a counter), so none is a
298
+ `transit` field; the request text itself still travels only in `inputs[].inline`. There is no field for a token or a path (the object is closed).
299
+ - **What an LA must do with it.** `dryRun:true` means: run the whole loop but push nothing, open no pull request and notify nobody. `createPullRequest:false`
300
+ means: do not open one (the branch may be pushed when `createBranch` is not false). `createBranch:false` means: work on `repoRef.branch` without creating a branch.
301
+ `maxIterations` caps the agent loop below the LA's own cap (never above it). `branchName`/`ticketKey` name the branch to create. An LA never widens its own policy because
302
+ of an option: the budget and the policy of the same payload still bound the job.
303
+ - **Rule for the CP.** Options that change external effects (`dryRun:true`, `createPullRequest:false`) are sent only to an LA that announced `job.options`; if none
304
+ did, the CP REFUSES the job (`OPTION_NOT_SUPPORTED`) instead of running one with a real pull request. The other options (ticket, branch, `createBranch`,
305
+ `maxIterations`) are an optimisation of the same default behaviour: for an LA without `job.options` the CP sends the job without `options`.
306
+ - **Compatible.** A `job.assign` without `options` is valid under 2.0 and 2.1 and means what it always meant. The CP gateway side is `deepdebug.jobs.dispatch-to-la.enabled`
307
+ (default off); the vectors are `vectors/job.assign.vectors.json` (`options-*`).
308
+
285
309
  ### Compatibility and roll-out order
286
310
  Everything is additive and optional: a 2.0 frame is valid under the 2.1 schemas (the vectors of 2.0 are unchanged and still pass), and a 2.1 peer talking to a
287
311
  2.0 peer sends nothing the 2.0 peer cannot read, because each new field is gated by a capability the other side announced (above). Roll out the gateway that
@@ -184,6 +184,37 @@
184
184
  }
185
185
  ]
186
186
  }
187
+ },
188
+ "options": {
189
+ "type": "object",
190
+ "additionalProperties": false,
191
+ "description": "Optional (revision 2.1, capability job.options): how to run the job. Absent = the Local Agent's own defaults. Sent only to a Local Agent that announced job.options; none of these fields is content (a ticket id, a branch name, flags and a counter).",
192
+ "properties": {
193
+ "ticketKey": {
194
+ "$ref": "defs.schema.json#/$defs/shortId"
195
+ },
196
+ "branchName": {
197
+ "type": "string",
198
+ "minLength": 1,
199
+ "maxLength": 255,
200
+ "pattern": "^(?!.*\\.\\.)(?!.*//)(?!.*/\\.)(?!.*[./]$)(?!.*\\.lock(/|$))[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$"
201
+ },
202
+ "createBranch": {
203
+ "type": "boolean"
204
+ },
205
+ "createPullRequest": {
206
+ "type": "boolean"
207
+ },
208
+ "dryRun": {
209
+ "type": "boolean"
210
+ },
211
+ "maxIterations": {
212
+ "type": "integer",
213
+ "minimum": 1,
214
+ "maximum": 100
215
+ }
216
+ },
217
+ "minProperties": 1
187
218
  }
188
219
  },
189
220
  "required": [
@@ -0,0 +1,60 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_JOBS flag and limits of the job.assign receiver (F3-10).
3
+ *
4
+ * false (default) `job.assign` and `job.cancel` keep being acked `rejected protocol_unsupported`, the capability `job.assign` is NOT announced:
5
+ * nothing of this folder is loaded.
6
+ * true the agent receives `job.assign`, validates it, acks it and hands it to a RUNNER (runners.js); `job.cancel` stops it; progress,
7
+ * cost and the result go back as `job.event` / `job.result` (metadata only). Needs LOCAL_AGENT_PROTOCOL_V2=true and
8
+ * LOCAL_AGENT_DLP_MODE=enforce (a job reads client files), and a runner: LOCAL_AGENT_JOBS_RUNNER=smoke (the reference runner,
9
+ * which also needs the LLM client) or one passed to buildV2Link by the packaging of the agent loop (F3-08c). Without a runner the
10
+ * receiver is NOT installed and the capability is NOT announced.
11
+ *
12
+ * Unset/empty/unrecognised = false. Every number is clamped: a bad value falls back to the default, never to "unlimited".
13
+ */
14
+ export const JOBS_FLAG = "LOCAL_AGENT_JOBS";
15
+
16
+ const TRUE = new Set(["true", "1", "yes", "on"]);
17
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
18
+ const KIB = 1024;
19
+ const MIB = 1024 * KIB;
20
+
21
+ function int(raw, def, min, max) {
22
+ const n = Number.parseInt(String(raw ?? ""), 10);
23
+ return Number.isFinite(n) && n >= min && n <= max ? n : def;
24
+ }
25
+
26
+ export const KNOWN_RUNNERS = Object.freeze(["smoke"]);
27
+
28
+ export function resolveJobsConfig(env = process.env, warn = console.warn) {
29
+ const raw = String(env[JOBS_FLAG] ?? "").trim().toLowerCase();
30
+ if (!TRUE.has(raw)) {
31
+ if (!FALSE.has(raw)) warn(`[la-jobs] unrecognised ${JOBS_FLAG} value; job.assign stays off`);
32
+ return { enabled: false };
33
+ }
34
+ let runner = String(env.LOCAL_AGENT_JOBS_RUNNER ?? "").trim().toLowerCase() || null;
35
+ if (runner && !KNOWN_RUNNERS.includes(runner)) { warn("[la-jobs] unknown LOCAL_AGENT_JOBS_RUNNER; no runner selected"); runner = null; }
36
+ const maxRss = Number.parseInt(String(env.LOCAL_AGENT_JOBS_MAX_RSS_BYTES ?? ""), 10);
37
+ return {
38
+ enabled: true,
39
+ runner,
40
+ maxConcurrent: int(env.LOCAL_AGENT_JOBS_MAX_CONCURRENT, 1, 1, 8), // jobs running at once
41
+ maxQueued: int(env.LOCAL_AGENT_JOBS_MAX_QUEUED, 4, 0, 64), // accepted and waiting for a slot (state `queued`); beyond it: rate_limited
42
+ // a job asks for a budget; the agent never grants more than this (clamped, not refused: the job then ends budget_exceeded / timeout)
43
+ maxTokens: int(env.LOCAL_AGENT_JOBS_MAX_TOKENS, 2_000_000, 1, 10_000_000),
44
+ maxSeconds: int(env.LOCAL_AGENT_JOBS_MAX_SECONDS, 3_600, 1, 86_400),
45
+ // job.assign `options.maxIterations` caps the agent loop BELOW this ceiling, never above it (options.js); also the value when the job names none
46
+ maxIterations: int(env.LOCAL_AGENT_JOBS_MAX_ITERATIONS, 25, 1, 100),
47
+ // memory: each admitted job reserves its inline bytes + memPerJobBytes against maxMemoryBytes; with maxRssBytes > 0 a new job is also
48
+ // refused while the process RSS is above it (a runner is code in this process: the reservation is accounting, the RSS check is the brake)
49
+ memPerJobBytes: int(env.LOCAL_AGENT_JOBS_MEM_PER_JOB_BYTES, 32 * MIB, MIB, 4 * 1024 * MIB),
50
+ maxMemoryBytes: int(env.LOCAL_AGENT_JOBS_MAX_MEMORY_BYTES, 256 * MIB, 16 * MIB, 64 * 1024 * MIB),
51
+ maxRssBytes: Number.isFinite(maxRss) && maxRss >= 128 * MIB ? maxRss : 0,
52
+ eventMinIntervalMs: int(env.LOCAL_AGENT_JOBS_EVENT_MIN_MS, 1_000, 50, 60_000), // progress job.event coalescing (the last one wins); terminal ones are never dropped
53
+ terminalRetries: int(env.LOCAL_AGENT_JOBS_TERMINAL_RETRIES, 20, 0, 200),
54
+ terminalRetryMs: int(env.LOCAL_AGENT_JOBS_TERMINAL_RETRY_MS, 500, 10, 10_000),
55
+ cancelGraceMs: int(env.LOCAL_AGENT_JOBS_CANCEL_GRACE_MS, 10_000, 100, 120_000), // after job.cancel a runner that has not stopped is abandoned
56
+ recentJobs: 256, // finished job ids remembered, so a repeated job.assign is idempotent
57
+ smokeMaxFileBytes: int(env.LOCAL_AGENT_JOBS_SMOKE_MAX_FILE_BYTES, 64 * KIB, KIB, MIB),
58
+ auditFile: String(env.LOCAL_AGENT_JOBS_AUDIT_FILE ?? "").trim() || null,
59
+ };
60
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * events.js - LA -> CP `job.event` and `job.result` of one job (F3-10). METADATA ONLY: the payloads are built from a fixed whitelist of
3
+ * counters, enums and ids (the contract has no free-text field; the session re-validates every frame against the closed schemas before a byte
4
+ * is written). Nothing of the work itself (prompts, answers, paths, diffs) can be put here.
5
+ *
6
+ * progress coalesced: at most one frame per `eventMinIntervalMs` per job, the LAST state wins (a fast runner cannot flood the channel; a
7
+ * full send queue (`rate_limited`) or a detached session drops that frame and counts it: progress is advisory).
8
+ * terminal `job.event` (succeeded|failed|cancelled) then `job.result`: never dropped on backpressure; retried every `terminalRetryMs`
9
+ * up to `terminalRetries` times while the queue is full or the session is between connections, then given up (audited).
10
+ */
11
+ import { ProtocolError } from "../errors.js";
12
+
13
+ const STATES = new Set(["queued", "running", "waiting_approval", "succeeded", "failed", "cancelled"]);
14
+ const PHASES = new Set(["init", "context", "plan", "edit", "validate", "pr", "done"]);
15
+ export const JOB_ERROR_CODES = Object.freeze(["none", "llm_error", "policy_denied", "build_failed", "tests_failed", "timeout", "cancelled", "budget_exceeded", "infra", "unknown"]);
16
+ const OUTCOMES = new Set(["pr_opened", "no_change", "failed", "cancelled"]);
17
+ const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
18
+
19
+ /** The `job.event` payload of a job snapshot (unknown or invalid fields are left out, never repaired). */
20
+ export function eventPayload(snap) {
21
+ const p = { jobId: snap.jobId, state: STATES.has(snap.state) ? snap.state : "running" };
22
+ if (PHASES.has(snap.phase)) p.phase = snap.phase;
23
+ if (Number.isInteger(snap.iteration) && snap.iteration >= 0 && snap.iteration <= 1000) p.iteration = snap.iteration;
24
+ for (const k of ["filesRead", "filesChanged", "tokensIn", "tokensOut", "durationMs"]) if (isCount(snap[k])) p[k] = snap[k];
25
+ if (typeof snap.costUsd === "number" && Number.isFinite(snap.costUsd) && snap.costUsd >= 0 && snap.costUsd <= 100000) p.costUsd = Math.round(snap.costUsd * 1e6) / 1e6;
26
+ if (JOB_ERROR_CODES.includes(snap.errorCode)) p.errorCode = snap.errorCode;
27
+ return p;
28
+ }
29
+
30
+ /** The `job.result` payload: the outcome and the fields a runner may report (the schema validates their shapes; an invalid one drops the whole extra set). */
31
+ export function resultPayload(jobId, outcome, extra = {}) {
32
+ const p = { jobId, outcome: OUTCOMES.has(outcome) ? outcome : "failed" };
33
+ for (const k of ["prUrl", "branch", "commitSha", "diffSha256"]) if (typeof extra[k] === "string") p[k] = extra[k];
34
+ if (isCount(extra.filesChangedCount)) p.filesChangedCount = extra.filesChangedCount;
35
+ const t = extra.tests;
36
+ if (t && isCount(t.pass) && isCount(t.fail) && isCount(t.skip)) p.tests = { pass: t.pass, fail: t.fail, skip: t.skip };
37
+ return p;
38
+ }
39
+
40
+ const RETRYABLE = new Set(["rate_limited", "not_ready"]);
41
+
42
+ export function createJobEvents({ session, config, audit = null, now = Date.now, sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); }) }) {
43
+ const stats = { progressSent: 0, progressDropped: 0, terminalSent: 0, terminalGivenUp: 0 };
44
+ const timers = new Set();
45
+ let closed = false;
46
+ const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* never breaks the data path */ } };
47
+
48
+ async function enqueueOnce(type, payload, workspaceId) {
49
+ const h = await session.enqueue(type, payload, { workspaceId });
50
+ h.acked.catch(() => {}); // the session retransmits; a lost ack is not this module's business
51
+ }
52
+
53
+ async function sendReliable(type, payload, workspaceId, jobShort) {
54
+ for (let i = 0; ; i++) {
55
+ if (closed) return { ok: false, code: "agent_offline" };
56
+ try { await enqueueOnce(type, payload, workspaceId); return { ok: true }; } catch (e) {
57
+ const code = e instanceof ProtocolError ? e.code : "internal";
58
+ if (code === "schema_violation") return { ok: false, code }; // the caller may fall back to a smaller payload
59
+ if (!RETRYABLE.has(code) || i >= config.terminalRetries) { stats.terminalGivenUp++; note("job_event_given_up", { jobShort, type, code, tries: i + 1 }); return { ok: false, code }; }
60
+ await sleep(config.terminalRetryMs);
61
+ }
62
+ }
63
+ }
64
+
65
+ return {
66
+ stats,
67
+ /** Advisory: coalesced, may be dropped. `job.ev` is this module's private per-job state. */
68
+ progress(job, snap) {
69
+ if (closed) return;
70
+ const ev = job.ev || (job.ev = { last: 0, timer: null, pending: null });
71
+ ev.pending = eventPayload(snap);
72
+ if (ev.timer) return;
73
+ const flush = () => {
74
+ ev.timer = null;
75
+ const payload = ev.pending;
76
+ ev.pending = null;
77
+ if (!payload || closed) return;
78
+ ev.last = now();
79
+ enqueueOnce("job.event", payload, job.workspaceId).then(() => { stats.progressSent++; }, () => { stats.progressDropped++; });
80
+ };
81
+ const wait = Math.max(0, ev.last + config.eventMinIntervalMs - now());
82
+ if (wait === 0) { flush(); return; }
83
+ ev.timer = setTimeout(flush, wait);
84
+ if (ev.timer.unref) ev.timer.unref();
85
+ timers.add(ev.timer);
86
+ },
87
+ /**
88
+ * The end: the pending progress frame is discarded (the terminal event carries the totals). The RESULT goes out BEFORE the terminal
89
+ * event: the control plane closes the job on the first terminal frame it sees and drops anything that comes after it, so a result
90
+ * sent after the terminal event (prUrl, branch, files changed) would be lost. Both are always sent; true only if both were accepted.
91
+ */
92
+ async terminal(job, snap, result) {
93
+ const ev = job.ev;
94
+ if (ev) { if (ev.timer) { clearTimeout(ev.timer); timers.delete(ev.timer); ev.timer = null; } ev.pending = null; }
95
+ const jobShort = job.short;
96
+ let r = await sendReliable("job.result", result, job.workspaceId, jobShort);
97
+ // a runner-supplied field the schema refuses (a malformed PR url) must not lose the result: send the outcome alone
98
+ if (!r.ok && r.code === "schema_violation") r = await sendReliable("job.result", { jobId: result.jobId, outcome: result.outcome }, job.workspaceId, jobShort);
99
+ const okResult = r.ok;
100
+ const okEvent = (await sendReliable("job.event", eventPayload(snap), job.workspaceId, jobShort)).ok;
101
+ if (okEvent && okResult) stats.terminalSent++;
102
+ return okEvent && okResult;
103
+ },
104
+ close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
105
+ };
106
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * install.js - puts the job.assign receiver on a v2 link (F3-10). Called by runtime.js only when LOCAL_AGENT_JOBS=true, v2 is on, DLP is enforce and a
3
+ * runner exists. Registers `job.assign` and `job.cancel`; `link.jobs` is the manager (setRunner() is how F3-08c plugs the packaged agent loop in).
4
+ *
5
+ * Git credentials: the control plane NEVER sends a token (the job.assign schema has no field for one, and it is closed). A runner that opens a PR uses
6
+ * what this machine already has: the user's git credential helper, ssh-agent or the environment of the agent's own process. See docs/la-jobs-and-llm.md.
7
+ */
8
+ import { dlpConfigFromEnv } from "../../dlp/index.js";
9
+ import { createJobManager } from "./manager.js";
10
+ import { createSmokeRunner } from "./smoke-runner.js";
11
+
12
+ export const JOBS_CAPABILITIES = Object.freeze(["job.assign"]);
13
+
14
+ export function installJobs({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console, runner = null, onResult = null, now = Date.now, memoryUsage, sleep }) {
15
+ if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("job.assign needs LOCAL_AGENT_DLP_MODE=enforce");
16
+ const tenant = String(tenantId).toLowerCase();
17
+ const resolveWorkspace = (workspaceId) => {
18
+ if (typeof workspaceId !== "string" || !workspaceId) return null;
19
+ const listed = (wsManager?.list?.() || []).find((w) => w && w.id === workspaceId);
20
+ if (listed) {
21
+ if (listed.tenantId && String(listed.tenantId).toLowerCase() !== tenant) return null;
22
+ return listed.root ? { root: listed.root } : null;
23
+ }
24
+ if (workspaceId === "default") { const root = getWorkspaceRoot(); return root ? { root } : null; }
25
+ return null;
26
+ };
27
+ const theRunner = runner || (config.runner === "smoke" ? createSmokeRunner({ maxFileBytes: config.smokeMaxFileBytes }) : null);
28
+ if (!theRunner) throw new Error("job.assign needs a runner");
29
+ const manager = createJobManager({ session: link.session, tenantId: tenant, resolveWorkspace, config, env, llm: link.llm || null,
30
+ readFile: link.content ? link.content.readAllowedFile : null, runner: theRunner, onResult, log, now, ...(memoryUsage ? { memoryUsage } : {}), ...(sleep ? { sleep } : {}) });
31
+ link.session.setHandler("job.assign", (ctx) => manager.handleAssign(ctx));
32
+ link.session.setHandler("job.cancel", (ctx) => manager.handleCancel(ctx));
33
+ const closeLink = link.close.bind(link);
34
+ link.close = () => { manager.cancelAll(); closeLink(); };
35
+ link.jobs = manager;
36
+ return manager;
37
+ }