deepdebug-local-agent 1.12.1 → 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.
- package/CHANGELOG.md +2 -0
- package/docs/la-chat.md +35 -0
- package/package.json +1 -1
- package/src/dlp/rules.js +2 -2
- package/src/la-protocol-v2/chat/config.js +60 -0
- package/src/la-protocol-v2/chat/cursors.js +57 -0
- package/src/la-protocol-v2/chat/dlp.js +92 -0
- package/src/la-protocol-v2/chat/index.js +28 -0
- package/src/la-protocol-v2/chat/store.js +337 -0
- package/src/la-protocol-v2/chat/stream-source.js +23 -0
- package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
- package/src/la-protocol-v2/content-handlers/handler.js +10 -2
- package/src/la-protocol-v2/content-handlers/install.js +2 -2
- package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +42 -0
- package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
- package/src/la-protocol-v2/contract/index.json +1 -1
- package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
- package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
- package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
- package/src/la-protocol-v2/jobs/manager.js +4 -2
- package/src/la-protocol-v2/runtime.js +32 -3
- package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
- package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
- package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
- package/test/la-protocol-v2/chat-la.test.js +490 -0
- package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
- package/test/la-protocol-v2/chat-store.test.js +301 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +6 -6
- package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
- package/test/la-protocol-v2/traces.test.js +3 -3
|
@@ -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
|
+
}
|
|
@@ -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":
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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). */
|