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
@@ -5,6 +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.3** (`index.json` `revision`, ADR-0014 Design A, last section): chat on the Local Agent. No new frame type: `job.assign.kind` gains `chat`, the inline input grows to 16384 chars, two body schemas (`job.stream.body`, `chat.session.body`) and the capabilities `job.chat` and `job.inline.16k`.
8
9
  **Revision 2.2** (`index.json` `revision`, F3-08c, last section): two new frame types, `policy.check` / `policy.decision`, and the capability `policy.check`.
9
10
  **Revision 2.1** (Fase 3): optional LLM key delivery, chunked webhooks, size/model in `incident.candidate`, `ready.capabilities`, `job.assign.options` and the capabilities `job.assign` / `job.options`.
10
11
 
@@ -13,6 +14,8 @@ exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json`
13
14
  one per allowlisted type, each `$ref`s the envelope and closes `payload`. `defs.schema.json`: shared types.
14
15
  - `file.list.body.schema.json` (F3-07c): not a frame type; the schema of the DECODED body of a `content.response` for kind `file.list`
15
16
  (below). `vectors/file.list.body.vectors.json` holds its vectors (same format, `schema` names that file).
17
+ - `job.stream.body.schema.json` and `chat.session.body.schema.json` (2.3): the same kind of file (decoded bodies of a `content.response`, not frames) for the
18
+ content kinds `job.stream` and `chat.session`; their vectors are `vectors/job.stream.body.vectors.json` and `vectors/chat.session.body.vectors.json`.
16
19
  - `index.json`: allowlist, direction, signed/control flags, content class per type (`none`, `relay`, `transit`, `secret`), the limits below and the contract `revision`.
17
20
  - Vector files of a BODY that travels inside a frame (not a frame) are named `*.body.vectors.json` and their schema `*.body.schema.json`; the frame
18
21
  suites skip them (`ProtocolV2ContractTest` checks them against their own schema, `SchemaLaV2ContractTest` does not read them as frames).
@@ -359,3 +362,42 @@ Nothing of a check is logged beyond ids, the action type and the outcome.
359
362
  **Compatible.** Additive: the 22 types, their schemas and their vectors are unchanged; the envelope `type` enum gained two names. Roll out the gateway first
360
363
  (it accepts both shapes and lists the capability only for an LA that announced it), then the LAs. Vectors: `vectors/policy.check.vectors.json` (5 valid, 20 invalid)
361
364
  and `vectors/policy.decision.vectors.json` (5 valid, 16 invalid).
365
+
366
+ ## Revision 2.3 (ADR-0014 Design A, GW-1): chat on the Local Agent
367
+ Decisions of Edu (Q1-Q10, 2026-11-04): for a tenant in `data_plane_mode=client` the chat loop runs on the Local Agent; the cloud only holds metadata. This
368
+ revision is **only the contract and its constants**: nothing in the gateway sends the new shapes yet (every flag stays off), so a 2.2 peer is not affected. All changes are
369
+ additive; every frame a 2.2 peer sends is still valid under the 2.3 schemas (the only widening is the inline `maxLength`, the only new value is the enum member `chat`).
370
+
371
+ ### (a) `job.assign.payload.kind = "chat"` and the capability `job.chat`
372
+ - `kind` enum is now `fix | implement | quality | chat`. `chat` runs the agent2 in its free (unified) mode on the Local Agent: the LA decides question, fix or feature. The
373
+ request text is `inputs[].inline` (transient), the workspace and repo are `workspaceId` + `repoRef` as for any job. `incidentRef` is absent for a chat.
374
+ - **Capability `job.chat`.** The LA announces it in `register` and `hello` (it implies `job.assign`). The CP sends `kind:"chat"` ONLY to a session that announced it
375
+ (`LocalAgentMessageSender.CAP_JOB_CHAT`, enforced in `AgentWebSocketHandler.requiredCapabilities`); with none, the caller gets `503 AGENT_UNSUPPORTED`. The CP never falls
376
+ back to a hosted agent. A 2.2 LA would answer `kind:"chat"` with a schema violation, which is why the capability exists.
377
+ - `job.result.outcome` for a chat is `no_change` (no pull request until a local provider credential exists, Q8) or `failed` / `cancelled`; no enum changed.
378
+ - A credential has no field: the payload stays closed (git credentials are local to the LA, Q1; vector `chat-inline-with-git-token`). `/ask` is not a job kind (Q6; vector `kind-ask-outside-allowlist`).
379
+
380
+ ### (b) Inline input up to 16384 chars and the capability `job.inline.16k`
381
+ - `inputs[].inline.maxLength` goes from 4096 to **16384** (still `x-content-class: transit`, `transient: true`, not persisted; `paramsRaw` echoes it).
382
+ - **Capability `job.inline.16k`.** The CP sends an inline input above 4096 chars ONLY to a session that announced it (`CAP_JOB_INLINE_16K`, `INLINE_LEGACY_MAX_CHARS` = 4096,
383
+ `INLINE_16K_MAX_CHARS` = 16384). An LA without it would answer a schema violation. Both capability names match the `capability` pattern: no enum changes.
384
+ - **Budget.** A CP->LA frame repeats the payload in `paramsRaw`: 16384 chars of inline appear twice, about 34 KiB with the rest of the payload and the signature,
385
+ under the 48 KiB frame limit (`valid-chat-job-and-stream` trace carries a full 16384-char inline). The "about 20 KiB" effective payload in "Limits" above refers to the
386
+ general case; an inline of 16384 chars is the largest single field the schema allows, and the frame check (`frame_too_large`) still applies to the whole frame.
387
+ Non-ASCII text counts by UTF-8 bytes in the frame: 16384 four-byte characters would not fit, and the CP must check the frame size before sending.
388
+
389
+ ### (c) Bodies of `content.response` for `job.stream` and `chat.session` (no frame change)
390
+ `content.request.kind` already listed `job.stream` and `chat.session` and the opaque-ref convention above already allows a UUID `ref` for them. 2.3 defines the decoded body of the answer
391
+ (client content in transit: DLP at the LA, never parsed for storage, persisted or logged by the CP; same bounds as any `content.response`, up to 2 MiB in 64 chunks):
392
+ - **`job.stream.body`**: `{events:[{i, e}], nextRef, done}`. Long-poll: the first request has `ref` = the job UUID; the LA holds it up to about 8 s (the CP gives 10 s to the
393
+ first chunk and 30 s in total) and answers with the events since the cursor, or an empty page with `done:false`. `e` is one event line as the agent2 emits it (1..16384
394
+ chars), `i` its 0-based position in the job's stream, `nextRef` the opaque 43-character cursor the LA minted (the convention above: base64url, no prefix; the CP cannot reverse
395
+ it); the next request uses it as `ref`. `done:true` closes the stream. At most 512 events per page. A cursor the LA does not know is `not_found`.
396
+ - **`chat.session.body`**: `{turns:[{role, ts, text}], truncated}`, `role` in `user | assistant`, `ts` UTC, `text` up to 65536 chars, up to 1000 turns, `truncated:true` when the LA cut the
397
+ oldest turns to fit the content cap. `ref` is the conversation UUID. Decision: `turns`, not the UI's `messages` shape (the gateway maps it for the UI).
398
+ - `content.request` itself did not change (no `waitMs`: the LA uses its default of about 8 s for `job.stream`). Vectors: `job-stream-*` and `chat-session-*` in `vectors/content.request.vectors.json`.
399
+
400
+ ### Compatibility and roll-out order
401
+ Gateway with the 2.3 schemas first (it accepts both shapes and sends nothing new while the flags are off), then the LA announces `job.chat` / `job.inline.16k` only when its
402
+ handlers exist. Vectors: `job.assign.vectors.json` (`chat-*`, `inline-*`, `kind-*`), `register`/`hello` (`capabilities-job-chat-and-inline-16k`), the two `*.body.vectors.json`, and the
403
+ trace `vectors/traces/valid-chat-job-and-stream.json`. The existing vector `inline-above-limit` now uses 16385 chars.
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.visionhammer.io/la-protocol-v2/chat.session.body.schema.json",
4
+ "title": "LA protocol v2: decoded body of a content.response for kind chat.session (not a frame type)",
5
+ "description": "The stored conversation of ONE chat session (ref = conversation UUID), read by the control plane from the Local Agent's ChatStore on disk when a person reopens the chat. Client content in transit: relayed, never stored or logged by the CP. `turns` are in order; `truncated` is true when the Local Agent cut the oldest turns to fit the content cap (README). Decision 2.3: `turns` (role, ts, text), not the FE's `messages` shape; the gateway maps it for the UI.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "turns": {
10
+ "type": "array",
11
+ "maxItems": 1000,
12
+ "items": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "role": {
17
+ "type": "string",
18
+ "enum": [
19
+ "user",
20
+ "assistant"
21
+ ]
22
+ },
23
+ "ts": {
24
+ "$ref": "defs.schema.json#/$defs/ts"
25
+ },
26
+ "text": {
27
+ "type": "string",
28
+ "maxLength": 65536,
29
+ "x-content-class": "relay"
30
+ }
31
+ },
32
+ "required": [
33
+ "role",
34
+ "ts",
35
+ "text"
36
+ ]
37
+ }
38
+ },
39
+ "truncated": {
40
+ "type": "boolean"
41
+ }
42
+ },
43
+ "required": [
44
+ "turns",
45
+ "truncated"
46
+ ]
47
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "protocol": 2,
3
- "revision": "2.2",
3
+ "revision": "2.3",
4
4
  "baseId": "https://schemas.visionhammer.io/la-protocol-v2/",
5
5
  "limits": {
6
6
  "maxFrameBytes": 49152,
@@ -24,7 +24,8 @@
24
24
  "enum": [
25
25
  "fix",
26
26
  "implement",
27
- "quality"
27
+ "quality",
28
+ "chat"
28
29
  ]
29
30
  },
30
31
  "incidentRef": {
@@ -170,7 +171,7 @@
170
171
  "inline": {
171
172
  "type": "string",
172
173
  "minLength": 1,
173
- "maxLength": 4096,
174
+ "maxLength": 16384,
174
175
  "x-content-class": "transit"
175
176
  },
176
177
  "transient": {
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.visionhammer.io/la-protocol-v2/job.stream.body.schema.json",
4
+ "title": "LA protocol v2: decoded body of a content.response for kind job.stream (not a frame type)",
5
+ "description": "One long-poll page of the event stream of ONE job (kind chat in 2.3): what the Local Agent puts, as JSON, in the base64 `data` of a content.response (contentType application/json). Client content in transit (the model's answer): the control plane relays it, never parses for storage, persists or logs it. `events[].e` is one event line exactly as the agent2 emits it; `i` is its 0-based position in the job's stream; `nextRef` is the opaque 43-character cursor the Local Agent minted: ask job.stream again with it as `ref` to continue after the last event of this page; `done` is true when the job's stream is closed and no event follows. An empty `events` with done=false is a long-poll that timed out (ask again). The first request uses the job UUID as `ref`. Total size of the body is bounded by the content relay (README), which JSON Schema cannot state.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "events": {
10
+ "type": "array",
11
+ "maxItems": 512,
12
+ "items": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "i": {
17
+ "type": "integer",
18
+ "minimum": 0,
19
+ "maximum": 9007199254740991
20
+ },
21
+ "e": {
22
+ "type": "string",
23
+ "minLength": 1,
24
+ "maxLength": 16384,
25
+ "x-content-class": "relay"
26
+ }
27
+ },
28
+ "required": [
29
+ "i",
30
+ "e"
31
+ ]
32
+ }
33
+ },
34
+ "nextRef": {
35
+ "type": "string",
36
+ "minLength": 43,
37
+ "maxLength": 43,
38
+ "pattern": "^[A-Za-z0-9_-]{43}$"
39
+ },
40
+ "done": {
41
+ "type": "boolean"
42
+ }
43
+ },
44
+ "required": [
45
+ "events",
46
+ "nextRef",
47
+ "done"
48
+ ]
49
+ }
@@ -36,6 +36,59 @@ async function readJson(res, maxBytes = MAX_JSON_BYTES) {
36
36
  try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return undefined; }
37
37
  }
38
38
 
39
+ /**
40
+ * Server-Sent Events reader shared by `events` and `stream`: `onEvent(eventName, dataLines[])` for every complete event. The caller picks target and token.
41
+ */
42
+ async function sseRead(getTarget, fetchImpl, path, { signal, idleMs, onEvent }) {
43
+ const { baseUrl, token } = getTarget();
44
+ const ac = new AbortController();
45
+ let idle = null;
46
+ const arm = () => { if (!idleMs) return; clearTimeout(idle); idle = setTimeout(() => ac.abort(), idleMs); };
47
+ const onAbort = () => ac.abort();
48
+ if (signal) { if (signal.aborted) throw new Agent2ApiError("cancelled"); signal.addEventListener("abort", onAbort, { once: true }); }
49
+ let res;
50
+ try {
51
+ arm();
52
+ res = await fetchImpl(`${baseUrl}/local/v1${path}`, { method: "GET", headers: { accept: "text/event-stream", authorization: `Bearer ${token}` }, signal: ac.signal });
53
+ if (res.status !== 200) { try { await res.body?.cancel(); } catch { /* gone */ } throw new Agent2ApiError(res.status === 404 ? "not_found" : res.status === 401 ? "unauthorized" : "http_error", { status: res.status }); }
54
+ if (!res.body || typeof res.body.getReader !== "function") throw new Agent2ApiError("bad_response");
55
+ const reader = res.body.getReader();
56
+ const dec = new TextDecoder("utf-8");
57
+ let buf = "";
58
+ let event = "message";
59
+ let data = [];
60
+ const dispatch = () => {
61
+ if (data.length || event !== "message") onEvent(event, data);
62
+ event = "message"; data = [];
63
+ };
64
+ for (;;) {
65
+ const { done, value } = await reader.read();
66
+ if (done) break;
67
+ arm();
68
+ buf += dec.decode(value, { stream: true });
69
+ if (buf.length > MAX_SSE_LINE_BYTES * 2) throw new Agent2ApiError("bad_response");
70
+ let nl;
71
+ while ((nl = buf.search(/\r?\n/)) >= 0) {
72
+ const line = buf.slice(0, nl);
73
+ buf = buf.slice(buf[nl] === "\r" ? nl + 2 : nl + 1);
74
+ if (line === "") dispatch();
75
+ else if (line.startsWith("event:")) event = line.slice(6).trim();
76
+ else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
77
+ // comments (`:`), `id:` and `retry:` are ignored
78
+ }
79
+ }
80
+ if (buf.trim() === "" ) dispatch();
81
+ return { ended: true };
82
+ } catch (e) {
83
+ if (e instanceof Agent2ApiError) throw e;
84
+ throw new Agent2ApiError(signal?.aborted ? "cancelled" : "stream_broken");
85
+ } finally {
86
+ clearTimeout(idle);
87
+ signal?.removeEventListener("abort", onAbort);
88
+ try { ac.abort(); } catch { /* done */ }
89
+ }
90
+ }
91
+
39
92
  export function createAgent2Api({ getTarget, requestTimeoutMs = 10_000, fetchImpl = (...a) => globalThis.fetch(...a) }) {
40
93
  /** getTarget() -> { baseUrl, token } of the agent2 that is up now (the token changes at every start of a supervised process). */
41
94
  async function call(method, path, { body, signal, timeoutMs = requestTimeoutMs, auth = true } = {}) {
@@ -66,11 +119,21 @@ export function createAgent2Api({ getTarget, requestTimeoutMs = 10_000, fetchImp
66
119
  }
67
120
  }
68
121
 
122
+ const readSse = (path, o) => sseRead(getTarget, fetchImpl, path, o);
123
+
69
124
  return {
70
125
  /** GET /health (no token). true when agent2 answers `status: up`. */
71
126
  async health(signal) {
72
127
  try { const r = await call("GET", "/health", { auth: false, timeoutMs: Math.min(requestTimeoutMs, 3_000), signal }); return !!r.json && r.json.status === "up"; } catch { return false; }
73
128
  },
129
+ /** GET /health -> { up, kinds }: `kinds` is what agent2 runs (`["fix","implement"]`, plus `"chat"` with AGENT2_LOCAL_CHAT_ENABLED); an agent2 without the field has none listed. */
130
+ async healthInfo(signal) {
131
+ try {
132
+ const r = await call("GET", "/health", { auth: false, timeoutMs: Math.min(requestTimeoutMs, 3_000), signal });
133
+ const j = r.json && typeof r.json === "object" ? r.json : {};
134
+ return { up: j.status === "up", kinds: Array.isArray(j.kinds) ? j.kinds.filter((k) => typeof k === "string" && /^[a-z][a-z0-9._-]{0,31}$/.test(k)).slice(0, 16) : [] };
135
+ } catch { return { up: false, kinds: [] }; }
136
+ },
74
137
  async postJob(body, signal) { return (await call("POST", "/jobs", { body, signal })).json; },
75
138
  async cancel(jobId, reason = "user", signal) { return (await call("POST", `/jobs/${encodeURIComponent(jobId)}/cancel`, { body: { reason }, signal })).json; },
76
139
  async jobState(jobId, signal) { return (await call("GET", `/jobs/${encodeURIComponent(jobId)}`, { signal })).json; },
@@ -94,57 +157,21 @@ export function createAgent2Api({ getTarget, requestTimeoutMs = 10_000, fetchImp
94
157
  * rejects with Agent2ApiError on a non-200, a broken stream or an abort. The stream replays from the start: the caller skips what it already handled.
95
158
  */
96
159
  async events(jobId, { signal, onFrame, idleMs = 0 }) {
97
- const { baseUrl, token } = getTarget();
98
- const ac = new AbortController();
99
- let idle = null;
100
- const arm = () => { if (!idleMs) return; clearTimeout(idle); idle = setTimeout(() => ac.abort(), idleMs); };
101
- const onAbort = () => ac.abort();
102
- if (signal) { if (signal.aborted) throw new Agent2ApiError("cancelled"); signal.addEventListener("abort", onAbort, { once: true }); }
103
- let res;
104
- try {
105
- arm();
106
- res = await fetchImpl(`${baseUrl}/local/v1/jobs/${encodeURIComponent(jobId)}/events`, { method: "GET", headers: { accept: "text/event-stream", authorization: `Bearer ${token}` }, signal: ac.signal });
107
- if (res.status !== 200) { try { await res.body?.cancel(); } catch { /* gone */ } throw new Agent2ApiError(res.status === 404 ? "not_found" : res.status === 401 ? "unauthorized" : "http_error", { status: res.status }); }
108
- if (!res.body || typeof res.body.getReader !== "function") throw new Agent2ApiError("bad_response");
109
- const reader = res.body.getReader();
110
- const dec = new TextDecoder("utf-8");
111
- let buf = "";
112
- let event = "message";
113
- let data = [];
114
- const dispatch = () => {
115
- if (event === "frame" && data.length) {
116
- let frame;
117
- try { frame = JSON.parse(data.join("\n")); } catch { frame = undefined; }
118
- if (frame !== undefined) onFrame(frame);
119
- }
120
- event = "message"; data = [];
121
- };
122
- for (;;) {
123
- const { done, value } = await reader.read();
124
- if (done) break;
125
- arm();
126
- buf += dec.decode(value, { stream: true });
127
- if (buf.length > MAX_SSE_LINE_BYTES * 2) throw new Agent2ApiError("bad_response");
128
- let nl;
129
- while ((nl = buf.search(/\r?\n/)) >= 0) {
130
- const line = buf.slice(0, nl);
131
- buf = buf.slice(buf[nl] === "\r" ? nl + 2 : nl + 1);
132
- if (line === "") dispatch();
133
- else if (line.startsWith("event:")) event = line.slice(6).trim();
134
- else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
135
- // comments (`:`), `id:` and `retry:` are ignored
136
- }
137
- }
138
- if (buf.trim() === "" ) dispatch();
139
- return { ended: true };
140
- } catch (e) {
141
- if (e instanceof Agent2ApiError) throw e;
142
- throw new Agent2ApiError(signal?.aborted ? "cancelled" : "stream_broken");
143
- } finally {
144
- clearTimeout(idle);
145
- signal?.removeEventListener("abort", onAbort);
146
- try { ac.abort(); } catch { /* done */ }
147
- }
160
+ return readSse(`/jobs/${encodeURIComponent(jobId)}/events`, { signal, idleMs, onEvent: (event, data) => {
161
+ if (event !== "frame" || !data.length) return;
162
+ let frame;
163
+ try { frame = JSON.parse(data.join("\n")); } catch { frame = undefined; }
164
+ if (frame !== undefined) onFrame(frame);
165
+ } });
166
+ },
167
+
168
+ /**
169
+ * LA-3: `GET /jobs/{id}/stream`, the CONTENT of a run (agent2's chat; LOCAL ONLY). `onLine(text)` for every SSE event, the text being its `data` (one line of
170
+ * agent2's event format: a JSON string). Same rules as `events`: replays from the start, `{ ended: true }` when agent2 ends it, Agent2ApiError otherwise.
171
+ * The lines are customer content: the caller hands them to the ChatStore and to nobody else.
172
+ */
173
+ async stream(jobId, { signal, onLine, idleMs = 0 }) {
174
+ return readSse(`/jobs/${encodeURIComponent(jobId)}/stream`, { signal, idleMs, onEvent: (_event, data) => { if (data.length) onLine(data.join("\n")); } });
148
175
  },
149
176
  };
150
177
  }
@@ -91,6 +91,7 @@ export function resolveAgent2RunnerConfig(env = process.env, warn = console.warn
91
91
  }
92
92
  const common = {
93
93
  enabled: true,
94
+ chat: false, // LA-1: runtime.js turns it on (LOCAL_AGENT_CHAT + prerequisites); it makes this agent start agent2 with AGENT2_LOCAL_CHAT_ENABLED=true
94
95
  passthrough: pass,
95
96
  laUrl: loopbackBase(env.LOCAL_AGENT_AGENT2_LA_URL) || `http://127.0.0.1:${int(env.LOCAL_AGENT_PORT ?? env.PORT, 5055, 1, 65535)}`, // this agent's own API: the bridge forwards agent2's /workspace calls to it, signed for agent2
96
97
  maxWorkspaceBodyBytes: int(env.LOCAL_AGENT_AGENT2_WORKSPACE_MAX_BODY_BYTES, 16 * MIB, 4 * KIB, 256 * MIB), // one /workspace request body (write_file, safe-patch, ...)
@@ -15,6 +15,13 @@
15
15
  * approval.decision --> POST /local/v1/approvals/{id} after the token check (approvals.js)
16
16
  * job.cancel / timeout --> POST /local/v1/jobs/{id}/cancel
17
17
  *
18
+ * kind `chat` (contract 2.3, LA-3; only with the `chat` dependency = LOCAL_AGENT_CHAT and an agent2 whose `/health` lists `chat`): the prompt is the job's inline input
19
+ * (up to 16 KiB of UTF-8, never cut: over it the job fails), agent2 runs its `unified` loop, and its content stream (`GET /jobs/{id}/stream`, customer content)
20
+ * is recorded in the ChatStore on THIS machine, masked, while the job runs. The control plane gets the same metadata frames as for any job; the conversation
21
+ * leaves only through `job.stream` / `chat.session` (content handlers). A chat opens no pull request (agent2 forces it): `job.result.outcome` is `no_change`,
22
+ * `failed` or `cancelled`, and `filesChangedCount` carries what the loop changed on disk (the greater of agent2's own count and the largest `filesChanged` of its
23
+ * job.event frames), so "answered" and "answered and changed files locally" stay apart even though the outcome enum has no value for the second.
24
+ *
18
25
  * One job at a time (agent2 refuses a second with 409; runtime.js forces LOCAL_AGENT_JOBS_MAX_CONCURRENT=1 for this runner). Failure codes only: agent2
19
26
  * unavailable / crashed / stream lost = `infra`; agent2 refusing the job = `policy_denied` (403) or `unknown`.
20
27
  *
@@ -29,6 +36,7 @@ import { createAgent2Supervisor, Agent2UnavailableError } from "./supervisor.js"
29
36
  import { createApprovalDecider } from "./approvals.js";
30
37
  import { normalizeFrame } from "./frames.js";
31
38
  import { createPolicyCheckRelay } from "./policy-check.js";
39
+ import { consumeAgent2Stream } from "../../chat/stream-source.js";
32
40
  import { internalSignatureHeaders } from "../../../utils/request-signing.js";
33
41
  import { internalAuthHeaders, resolveAuthConfig } from "../../../utils/agent-auth.js";
34
42
 
@@ -53,6 +61,8 @@ function truncateBytes(text, max) {
53
61
  return new TextDecoder("utf-8", { fatal: false }).decode(b.subarray(0, max)).replace(/�+$/, "");
54
62
  }
55
63
 
64
+ const CHAT_MAX_PROMPT_BYTES = 16 * 1024; // contract 2.3 job.inline.16k, and agent2's own limit for kind=chat
65
+
56
66
  const HTTP_TO_JOB = Object.freeze({ 403: "policy_denied", 401: "infra", 409: "infra" });
57
67
 
58
68
  /**
@@ -62,9 +72,10 @@ const HTTP_TO_JOB = Object.freeze({ 403: "policy_denied", 401: "infra", 409: "in
62
72
  * @param {(workspaceId:string)=>string|null} o.resolveWorkspaceRoot
63
73
  * @param {()=>object} o.getSession the v2 session (late-bound: the link does not exist yet when the runner is built)
64
74
  * @param {{keys:Map}} o.signing the trusted platform keys (approval tokens)
75
+ * @param {object} [o.chat] LA-3: the chat (chat/index.js createChat()); without it this runner does not list `chat` in its kinds
65
76
  * @param {Function} [o.policyRelay] ({body, job}) => Promise<{status, json}>; TEST SEAM. Default: the policy.check relay over the v2 session (no session / no answer = DENY)
66
77
  */
67
- export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, getSession, signing, env = process.env, log = console, policyRelay = null, fetchImpl, spawnImpl, now = Date.now, sleep = defaultSleep, rssOf, audit = null }) {
78
+ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, getSession, signing, env = process.env, log = console, policyRelay = null, fetchImpl, spawnImpl, now = Date.now, sleep = defaultSleep, rssOf, audit = null, chat = null }) {
68
79
  const tenant = String(tenantId).toLowerCase();
69
80
  const auditor = audit || createAudit({ now });
70
81
  const warn = (m) => (log.warn || log.log).call(log, m);
@@ -80,6 +91,7 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
80
91
  const approvals = createApprovalDecider({ api: supervisor.api, signing, tenantId: tenant, now, audit: auditor, log });
81
92
  const stats = { jobs: 0, ok: 0, failed: 0, cancelled: 0, busy: 0, framesDropped: 0, tenantViolations: 0, jobViolations: 0, auditForwarded: 0, auditGivenUp: 0, streamReconnects: 0, byCode: {} };
82
93
  let active = null; // { jobId, workspaceId }
94
+ let chatKinds = false; // agent2's `/health` listed `chat` (probeChat / start of a chat job)
83
95
 
84
96
  async function forwardAudit(payload, workspaceId, jobShort) {
85
97
  const session = getSession();
@@ -92,7 +104,7 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
92
104
  }
93
105
  }
94
106
 
95
- async function requestText(job, ctx) {
107
+ async function requestText(job, ctx, maxBytes = config.maxRequestTextBytes) {
96
108
  const parts = [];
97
109
  for (const i of job.inputs || []) {
98
110
  if (typeof i.inline === "string") parts.push(i.inline);
@@ -103,7 +115,18 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
103
115
  }
104
116
  let text = parts.join("\n\n").trim();
105
117
  if (!text) text = `${job.kind === "implement" ? "Implement the requested change in this repository." : "Investigate and fix the reported problem in this repository."}${job.incidentRef?.id ? ` Incident ${job.incidentRef.id}.` : ""}`;
106
- return truncateBytes(text, config.maxRequestTextBytes);
118
+ return truncateBytes(text, maxBytes);
119
+ }
120
+
121
+ /** Only the inline parts of a chat job's inputs: that is the person's prompt, the one thing the ChatStore keeps as the user's turn. */
122
+ const inlineText = (job) => (job.inputs || []).filter((i) => typeof i.inline === "string").map((i) => i.inline).join("\n\n");
123
+
124
+ /** Brings agent2 (and the bridge) up: the same steps before a job and before the start-up probe of the chat capability. */
125
+ async function ensureAgent2() {
126
+ const { baseUrl } = await bridge.listen();
127
+ if (config.mode === "attach" && config.callbackToken) bridge.setCallbackToken(config.callbackToken);
128
+ void baseUrl;
129
+ await supervisor.ensureReady();
107
130
  }
108
131
 
109
132
  function jobBody(job, root, text, token) {
@@ -122,7 +145,7 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
122
145
 
123
146
  const failed = (errorCode) => { stats.failed++; stats.byCode[errorCode] = (stats.byCode[errorCode] || 0) + 1; return { outcome: "failed", errorCode }; };
124
147
 
125
- async function start(job, ctx) {
148
+ async function run(job, ctx, rec) {
126
149
  stats.jobs++;
127
150
  const jobShort = short(job.jobId);
128
151
  if (active) { stats.busy++; note("job_refused_busy", { jobShort }); return failed("infra"); }
@@ -132,21 +155,28 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
132
155
  let cancelTimer = null;
133
156
  let onDown = null;
134
157
  let sendCancel = null;
158
+ let streamAc = null;
159
+ let streamTask = null;
135
160
  try {
136
161
  const root = resolveWorkspaceRoot(job.workspaceId);
137
162
  if (!root) return failed("infra");
138
163
  try {
139
- const { baseUrl } = await bridge.listen();
140
- if (config.mode === "attach" && config.callbackToken) bridge.setCallbackToken(config.callbackToken);
141
- void baseUrl;
142
- await supervisor.ensureReady();
164
+ await ensureAgent2();
143
165
  } catch (e) {
144
166
  note("agent2_unavailable", { jobShort, code: e instanceof Agent2UnavailableError ? e.code : "bridge", mode: config.mode });
145
167
  return failed("infra");
146
168
  }
147
169
  if (ctx.signal.aborted) return { outcome: "cancelled" };
170
+ if (rec) {
171
+ // a chat never runs as another kind: an agent2 that does not list `chat` (restarted without the flag, a sidecar of another build) fails the job
172
+ const info = await supervisor.api.healthInfo(ctx.signal);
173
+ chatKinds = info.up && info.kinds.includes("chat");
174
+ if (!chatKinds) { note("chat_unsupported_by_agent2", { jobShort }); return failed("infra"); }
175
+ }
148
176
  proxyToken = bridge.registerJob({ jobId: job.jobId, alias: job.llm.alias, llm: ctx.llm, workspaceId: job.workspaceId, incidentId: job.incidentRef?.id, root, repoRef: job.repoRef });
149
- const text = await requestText(job, ctx);
177
+ // a chat prompt is cut at 4 bytes past the limit only to be able to tell "too long" (a cut inside a character drops at most 3 bytes): it is refused below, never sent shortened
178
+ const text = await requestText(job, ctx, rec ? CHAT_MAX_PROMPT_BYTES + 4 : config.maxRequestTextBytes);
179
+ if (rec && utf8.encode(text).length > CHAT_MAX_PROMPT_BYTES) { note("chat_prompt_too_large", { jobShort }); return failed("unknown"); } // never cut a prompt silently
150
180
  try {
151
181
  await supervisor.api.postJob(jobBody(job, root, text, proxyToken), ctx.signal);
152
182
  } catch (e) {
@@ -155,6 +185,10 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
155
185
  return failed(HTTP_TO_JOB[e && e.status] || (e && (e.status === 400 || e.status === 422) ? "unknown" : "infra"));
156
186
  }
157
187
  ctx.progress({ phase: "context" });
188
+ if (rec) {
189
+ streamAc = new AbortController();
190
+ streamTask = consumeAgent2Stream({ api: supervisor.api, jobId: job.jobId, recorder: rec, signal: streamAc.signal, reconnects: config.streamReconnects, sleep, isDown: () => supervisor.state !== "ready" });
191
+ }
158
192
 
159
193
  // ---- cancellation: the job's signal reaches agent2, which reports `cancelled` itself; we wait for that for a while, then let go
160
194
  let cancelSent = false;
@@ -171,7 +205,7 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
171
205
  supervisor.on("down", onDown); // agent2 died: nothing more will come
172
206
 
173
207
  // ---- the event stream (replays from the start on a reconnect: skip what was handled)
174
- const st = { lastSeq: 0, result: null, terminal: null, waiting: false, forwards: [] };
208
+ const st = { lastSeq: 0, result: null, terminal: null, waiting: false, forwards: [], maxFilesChanged: 0 };
175
209
  const handle = (raw) => {
176
210
  const f = normalizeFrame(raw, { tenantId: tenant, jobId: job.jobId });
177
211
  if (!f) { stats.framesDropped++; return; }
@@ -180,6 +214,7 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
180
214
  st.lastSeq = f.seq;
181
215
  if (f.type === "job.event") {
182
216
  if (f.terminal) { st.terminal = f.payload; return; }
217
+ if (f.payload.filesChanged > st.maxFilesChanged) st.maxFilesChanged = f.payload.filesChanged;
183
218
  const waiting = f.payload.state === "waiting_approval";
184
219
  st.waiting = waiting;
185
220
  ctx.progress({ ...(f.payload.phase ? { phase: f.payload.phase } : {}), ...(f.payload.iteration !== undefined ? { iteration: f.payload.iteration } : {}),
@@ -207,6 +242,12 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
207
242
  await sleep(250 * reconnects);
208
243
  }
209
244
  await Promise.allSettled(st.forwards);
245
+ if (streamTask) {
246
+ // agent2 closes its content stream when the job is over: give it a moment to deliver the last lines (the answer), then let go
247
+ await Promise.race([streamTask, sleep(chat.config.streamDrainMs)]);
248
+ streamAc.abort();
249
+ await streamTask;
250
+ }
210
251
 
211
252
  // ---- the outcome
212
253
  const r = st.result;
@@ -221,6 +262,12 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
221
262
  }
222
263
  if (r.outcome === "cancelled") { stats.cancelled++; return { outcome: "cancelled" }; }
223
264
  stats.ok++;
265
+ if (rec) {
266
+ if (r.outcome === "pr_opened") note("chat_unexpected_pr", { jobShort }); // agent2 forces no PR for a chat; if it says otherwise, the metadata is passed on as it is and this is audited
267
+ const filesChangedCount = Math.max(Number.isSafeInteger(r.filesChangedCount) ? r.filesChangedCount : 0, st.maxFilesChanged);
268
+ return { outcome: r.outcome, ...(r.prUrl ? { prUrl: r.prUrl } : {}), ...(r.branch ? { branch: r.branch } : {}), ...(r.commitSha ? { commitSha: r.commitSha } : {}),
269
+ filesChangedCount, ...(r.diffSha256 ? { diffSha256: r.diffSha256 } : {}), ...(r.tests ? { tests: r.tests } : {}) };
270
+ }
224
271
  return { outcome: r.outcome, ...(r.prUrl ? { prUrl: r.prUrl } : {}), ...(r.branch ? { branch: r.branch } : {}), ...(r.commitSha ? { commitSha: r.commitSha } : {}),
225
272
  ...(r.filesChangedCount !== undefined ? { filesChangedCount: r.filesChangedCount } : {}), ...(r.diffSha256 ? { diffSha256: r.diffSha256 } : {}), ...(r.tests ? { tests: r.tests } : {}) };
226
273
  } finally {
@@ -228,17 +275,51 @@ export function createAgent2Runner({ config, tenantId, resolveWorkspaceRoot, get
228
275
  if (onDown) supervisor.off("down", onDown);
229
276
  if (sendCancel) ctx.signal.removeEventListener("abort", sendCancel);
230
277
  try { evAc.abort(); } catch { /* done */ }
278
+ if (streamAc) { try { streamAc.abort(); } catch { /* done */ } }
231
279
  approvals.forgetJob(job.jobId);
232
280
  if (proxyToken) bridge.revokeJob(job.jobId); // the per-job "key" stops working the moment the job is over
233
281
  if (active && active.jobId === job.jobId) active = null;
234
282
  }
235
283
  }
236
284
 
285
+ /** The job.assign entry point. A chat job is recorded in the ChatStore from the first moment (so `job.stream` finds it) and closed there whatever happens. */
286
+ async function start(job, ctx) {
287
+ if (job.kind !== "chat") return run(job, ctx, null);
288
+ if (!chat) { stats.jobs++; return failed("infra"); } // the manager only gets here for a kind the runner lists; defence in depth
289
+ let rec;
290
+ try { rec = chat.store.begin({ jobId: job.jobId, workspaceId: job.workspaceId, userText: inlineText(job) }); } catch {
291
+ stats.jobs++;
292
+ note("chat_store_unavailable", { jobShort: short(job.jobId) });
293
+ return failed("infra");
294
+ }
295
+ let outcome = null;
296
+ try {
297
+ outcome = await run(job, ctx, rec);
298
+ return outcome;
299
+ } finally {
300
+ rec.finish(outcome ? (outcome.outcome === "failed" ? "failed" : outcome.outcome === "cancelled" ? "cancelled" : "done") : ctx.signal.aborted ? "cancelled" : "failed");
301
+ }
302
+ }
303
+
237
304
  return {
238
305
  name: "agent2",
239
- kinds: ["fix", "implement"], // `quality` is not served by agent2 local mode (422 there): the manager acks it protocol_unsupported
306
+ // `quality` is not served by agent2 local mode (422 there): the manager acks it protocol_unsupported. `chat` only with the ChatStore AND an agent2 that listed it.
307
+ get kinds() { return chat && chatKinds ? ["fix", "implement", "chat"] : ["fix", "implement"]; },
240
308
  needsLlm: true,
241
309
  supportsOptions: true,
310
+ /** LA-1: true once agent2's `/health` listed `chat` (probeChat or the start of a chat job) and the ChatStore exists: what `job.chat` is announced on. */
311
+ get chatReady() { return !!chat && chatKinds; },
312
+ /** Starts/finds agent2 and reads the kinds it runs. In spawn mode this starts the process now (not at the first job). Never throws. */
313
+ async probeChat() {
314
+ if (!chat) return false;
315
+ try {
316
+ await ensureAgent2();
317
+ const info = await supervisor.api.healthInfo();
318
+ chatKinds = info.up && info.kinds.includes("chat");
319
+ } catch { chatKinds = false; }
320
+ if (!chatKinds) note("chat_probe_negative", { mode: config.mode });
321
+ return chatKinds;
322
+ },
242
323
  start,
243
324
  cancel(jobId) {
244
325
  if (active && active.jobId === jobId && active.sendCancel) active.sendCancel();
@@ -79,6 +79,8 @@ export function agent2ChildEnv({ config, env, tenantId, token, bridgeBase, callb
79
79
  });
80
80
  if (config.maxHeapMb > 0) out.JAVA_TOOL_OPTIONS = `-Xmx${config.maxHeapMb}m`;
81
81
  if (config.approvalWaitSeconds > 0) out.AGENT2_LOCAL_APPROVAL_WAIT_SECONDS = String(config.approvalWaitSeconds);
82
+ // LA-1 (ADR-0014): the chat is on in agent2 only when this agent runs it (LOCAL_AGENT_CHAT and its prerequisites); runtime.js sets `config.chat`
83
+ if (config.chat === true) out.AGENT2_LOCAL_CHAT_ENABLED = "true";
82
84
  return out;
83
85
  }
84
86
 
@@ -15,20 +15,20 @@ const KEEP_ENV = Object.freeze(["PATH", "HOME", "USERPROFILE", "LANG", "TMPDIR",
15
15
  "XDG_CONFIG_HOME", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "GH_TOKEN", "GITHUB_TOKEN", "GITLAB_TOKEN", "GH_CONFIG_DIR", "GH_HOST"]);
16
16
 
17
17
  /** Minimal env for git: the names above from this process (what a credential helper needs), never prompts, never the agent's own secrets. */
18
- export function gitEnv(env = process.env, { askpass = null, tokenFile = null } = {}) {
18
+ export function gitEnv(env = process.env, { askpass = null, tokenFile = null, host = null } = {}) {
19
19
  const out = {};
20
20
  for (const k of KEEP_ENV) if (env[k] !== undefined) out[k] = env[k];
21
21
  out.GIT_TERMINAL_PROMPT = "0";
22
22
  out.GCM_INTERACTIVE = "never";
23
23
  out.LC_ALL = "C";
24
- if (askpass && tokenFile) { out.GIT_ASKPASS = askpass; out.LA_GIT_TOKEN_FILE = tokenFile; out.SSH_ASKPASS = askpass; }
24
+ if (askpass && tokenFile) { out.GIT_ASKPASS = askpass; out.LA_GIT_TOKEN_FILE = tokenFile; out.LA_GIT_HOST = host || ""; out.SSH_ASKPASS = askpass; }
25
25
  return out;
26
26
  }
27
27
 
28
28
  /** A 0700 shell script that answers git's askpass questions from a token file: the username is a constant, the password is the file's content. POSIX only. */
29
29
  export function ensureAskpassScript(dir) {
30
30
  const file = path.join(dir, ".vh-git-askpass.sh");
31
- const body = "#!/bin/sh\ncase \"$1\" in\n Username*|username*) printf '%s\\n' 'x-access-token' ;;\n *) cat -- \"$LA_GIT_TOKEN_FILE\" ;;\nesac\n";
31
+ const body = "#!/bin/sh\ncase \"$1\" in\n Username*|username*) printf '%s\\n' 'x-access-token' ;;\n *) case \"$1\" in *\"//${LA_GIT_HOST}\"*|*\"@${LA_GIT_HOST}\"*) cat -- \"$LA_GIT_TOKEN_FILE\" ;; *) exit 1 ;; esac ;;\nesac\n";
32
32
  let current = null;
33
33
  try { current = fs.readFileSync(file, "utf8"); } catch { current = null; }
34
34
  if (current !== body) { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, body, { mode: 0o700 }); fs.renameSync(tmp, file); }
@@ -37,7 +37,8 @@ export function ensureAskpassScript(dir) {
37
37
  }
38
38
 
39
39
  /** The config every git call gets: no exotic transports (a URL is built by us, https only). */
40
- export const SAFE_GIT_CONFIG = Object.freeze(["-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=never", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"]);
40
+ export const SAFE_GIT_CONFIG = Object.freeze(["-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=never", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null",
41
+ "-c", "http.followRedirects=false", "-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=60"]);
41
42
 
42
43
  /**
43
44
  * @param {string[]} args after the safe config