@nanobpm/agentic 0.1.0 → 0.5.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/README.md +2 -1
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/dist/transcript/index.d.ts +2 -2
- package/dist/transcript/index.js +1 -1
- package/dist/transcript/schema.d.ts +23 -1
- package/dist/transcript/schema.js +34 -1
- package/dist/transcript/store.d.ts +93 -5
- package/dist/transcript/store.js +287 -6
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/src/transcript/index.ts +8 -0
- package/src/transcript/schema.test.ts +31 -4
- package/src/transcript/schema.ts +36 -1
- package/src/transcript/store.ts +438 -6
- package/src/transcript/turns.test.ts +334 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- package/dist/transcript/test-db.js +0 -41
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACP ingestion backend — ADR 0062, slice 2 (the preferred adapter target).
|
|
3
|
+
*
|
|
4
|
+
* {@link AcpSessionClient} connects an ACP-speaking harness (reference:
|
|
5
|
+
* `opencode acp`; also Claude via `claude-code-acp`, the Gemini lineage) to a
|
|
6
|
+
* Slice-1 {@link SessionEventSink} — normalising the harness's `session/update`
|
|
7
|
+
* stream into canonical {@link SessionEvent}s and driving/steering the session
|
|
8
|
+
* over JSON-RPC. It realises the three ADR 0062 §5 primitives:
|
|
9
|
+
*
|
|
10
|
+
* - `session/update` → **emit**: each notification is classified
|
|
11
|
+
* ({@link classifyUpdate}), streamed message chunks are coalesced into whole
|
|
12
|
+
* `assistant`/`reasoning`/`user` events, and the `tool_call`/`tool_call_update`
|
|
13
|
+
* lifecycle becomes canonical `tool-call`/`tool-result` events.
|
|
14
|
+
* - `session/load` → **restore**: replays the harness's prior history (delivered
|
|
15
|
+
* as `session/update`s during the load) back into the sink.
|
|
16
|
+
* - `agentCapabilities.loadSession` (from `initialize`) → the **durable-resume
|
|
17
|
+
* capability probe** the nano-workforce enrolment gate (slice 5) consumes.
|
|
18
|
+
*
|
|
19
|
+
* This backend owns event **identity** and **causality** (the `id`/`parentId`
|
|
20
|
+
* chain) exactly as `events.ts` prescribes — ACP carries neither — while the sink
|
|
21
|
+
* (a {@link SessionAdapter}) owns ordering and fencing. Keeping those split lets a
|
|
22
|
+
* `SessionBackend` be passed straight in as the sink with no adapter.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { AcpConnection } from "./jsonrpc.js";
|
|
26
|
+
import { classifyUpdate } from "./normalize.js";
|
|
27
|
+
import { ACP_CLIENT_METHOD, ACP_METHOD, ACP_PROTOCOL_VERSION, isRecord, parseInitializeResult, parseSessionId, } from "./protocol.js";
|
|
28
|
+
/**
|
|
29
|
+
* The ACP ingestion client. Bind it to a transport and a sink, `initialize`, then
|
|
30
|
+
* either `newSession` + `prompt` (drive) or `restore` an existing session id.
|
|
31
|
+
*/
|
|
32
|
+
export class AcpSessionClient {
|
|
33
|
+
#connection;
|
|
34
|
+
#sink;
|
|
35
|
+
#newEventId;
|
|
36
|
+
#clientInfo;
|
|
37
|
+
#lastId = null;
|
|
38
|
+
#buffer;
|
|
39
|
+
#collector = null;
|
|
40
|
+
#sessionId;
|
|
41
|
+
constructor(connection, sink, options = {}) {
|
|
42
|
+
this.#connection = connection;
|
|
43
|
+
this.#sink = sink;
|
|
44
|
+
this.#newEventId = options.newEventId ?? randomUUID;
|
|
45
|
+
this.#clientInfo = options.clientInfo ?? { name: "nano-agentic", version: "0" };
|
|
46
|
+
const permit = options.onPermissionRequest ?? defaultPermissionResponse;
|
|
47
|
+
connection.onNotification(ACP_CLIENT_METHOD.sessionUpdate, (params) => this.#ingest(params));
|
|
48
|
+
connection.onRequest(ACP_CLIENT_METHOD.requestPermission, (params) => permit(params));
|
|
49
|
+
}
|
|
50
|
+
/** The active session id (after `newSession`/`restore`), or `undefined`. */
|
|
51
|
+
get sessionId() {
|
|
52
|
+
return this.#sessionId;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Perform the `initialize` handshake and return the durable-resume capability
|
|
56
|
+
* probe. Must be called before any `session/*` method.
|
|
57
|
+
*/
|
|
58
|
+
async initialize() {
|
|
59
|
+
const result = await this.#connection.request(ACP_METHOD.initialize, {
|
|
60
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
61
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
62
|
+
clientInfo: this.#clientInfo,
|
|
63
|
+
});
|
|
64
|
+
const parsed = parseInitializeResult(result);
|
|
65
|
+
return {
|
|
66
|
+
protocolVersion: parsed.protocolVersion,
|
|
67
|
+
loadSession: parsed.agentCapabilities.loadSession,
|
|
68
|
+
durableResume: parsed.agentCapabilities.loadSession,
|
|
69
|
+
promptCapabilities: parsed.agentCapabilities.promptCapabilities,
|
|
70
|
+
agentCapabilities: parsed.rawAgentCapabilities,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Open a fresh session (`session/new`); stores and returns its id. */
|
|
74
|
+
async newSession(params) {
|
|
75
|
+
const result = await this.#connection.request(ACP_METHOD.sessionNew, {
|
|
76
|
+
cwd: params.cwd,
|
|
77
|
+
mcpServers: params.mcpServers ?? [],
|
|
78
|
+
});
|
|
79
|
+
this.#sessionId = parseSessionId(result);
|
|
80
|
+
return this.#sessionId;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* **restore** — load an existing session (`session/load`). The agent replays its
|
|
84
|
+
* prior history as `session/update` notifications, which this client ingests
|
|
85
|
+
* into the sink; when the load resolves, the pending message is flushed. Returns
|
|
86
|
+
* the canonical events reconstructed from the replayed history, in order.
|
|
87
|
+
*
|
|
88
|
+
* Only valid against an agent whose probe reported `durableResume: true`.
|
|
89
|
+
*/
|
|
90
|
+
async restore(sessionId, params) {
|
|
91
|
+
const collected = await this.#collecting(() => this.#connection.request(ACP_METHOD.sessionLoad, {
|
|
92
|
+
sessionId,
|
|
93
|
+
cwd: params.cwd,
|
|
94
|
+
mcpServers: params.mcpServers ?? [],
|
|
95
|
+
}));
|
|
96
|
+
this.#sessionId = sessionId;
|
|
97
|
+
return collected.events;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* **drive** — send a prompt (`session/prompt`) and resolve when the turn ends.
|
|
101
|
+
* All assistant output arrives as `session/update` notifications and is emitted
|
|
102
|
+
* to the sink during the call; the final buffered message is flushed on
|
|
103
|
+
* completion.
|
|
104
|
+
*/
|
|
105
|
+
async prompt(input) {
|
|
106
|
+
if (this.#sessionId === undefined) {
|
|
107
|
+
throw new Error("prompt requires an active session — call newSession() or restore() first");
|
|
108
|
+
}
|
|
109
|
+
const prompt = typeof input === "string" ? [{ type: "text", text: input }] : input;
|
|
110
|
+
const collected = await this.#collecting(() => this.#connection.request(ACP_METHOD.sessionPrompt, { sessionId: this.#sessionId, prompt }));
|
|
111
|
+
return { stopReason: readStopReason(collected.result), events: collected.events };
|
|
112
|
+
}
|
|
113
|
+
/** **steer** — request cancellation of the running turn (`session/cancel`, a notification). */
|
|
114
|
+
cancel() {
|
|
115
|
+
if (this.#sessionId === undefined)
|
|
116
|
+
return;
|
|
117
|
+
this.#connection.notify(ACP_METHOD.sessionCancel, { sessionId: this.#sessionId });
|
|
118
|
+
}
|
|
119
|
+
/** Close the underlying connection and flush any pending buffered message. */
|
|
120
|
+
close() {
|
|
121
|
+
this.#flushMessage();
|
|
122
|
+
this.#connection.close();
|
|
123
|
+
}
|
|
124
|
+
async #collecting(op) {
|
|
125
|
+
const events = [];
|
|
126
|
+
const previous = this.#collector;
|
|
127
|
+
this.#collector = events;
|
|
128
|
+
try {
|
|
129
|
+
const result = await op();
|
|
130
|
+
// Every session/update queued before the response has been ingested by now
|
|
131
|
+
// (ordered transport delivery); flush the final in-flight message.
|
|
132
|
+
this.#flushMessage();
|
|
133
|
+
return { result, events };
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
this.#collector = previous;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
#ingest(params) {
|
|
140
|
+
if (!isRecord(params))
|
|
141
|
+
return;
|
|
142
|
+
// session/update carries the target sessionId (ACP). Once this client is driving
|
|
143
|
+
// a session, ignore updates addressed to a *different* session — a late update
|
|
144
|
+
// from a previous session, or a misrouted one, must not corrupt this stream.
|
|
145
|
+
// Guarded on #sessionId being set: during session/load replay the id is not yet
|
|
146
|
+
// stored, and those updates legitimately belong to the session being restored.
|
|
147
|
+
const sessionId = params.sessionId;
|
|
148
|
+
if (this.#sessionId !== undefined && typeof sessionId === "string" && sessionId !== this.#sessionId) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const update = classifyUpdate(params.update);
|
|
152
|
+
switch (update.kind) {
|
|
153
|
+
case "message":
|
|
154
|
+
this.#appendMessage(update);
|
|
155
|
+
return;
|
|
156
|
+
case "tool-call": {
|
|
157
|
+
this.#flushMessage();
|
|
158
|
+
const { id, parentId } = this.#nextIdentity();
|
|
159
|
+
this.#push({ type: "tool-call", id, parentId, callId: update.callId, name: update.name, args: update.args });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
case "tool-result": {
|
|
163
|
+
this.#flushMessage();
|
|
164
|
+
const { id, parentId } = this.#nextIdentity();
|
|
165
|
+
this.#push({ type: "tool-result", id, parentId, callId: update.callId, ok: update.ok, result: update.result });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
case "ignored":
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
#appendMessage(update) {
|
|
173
|
+
const buffer = this.#buffer;
|
|
174
|
+
if (buffer !== undefined && buffer.role === update.role && sameMessage(buffer.messageId, update.messageId)) {
|
|
175
|
+
this.#buffer = { role: buffer.role, messageId: buffer.messageId, text: buffer.text + update.text };
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.#flushMessage();
|
|
179
|
+
this.#buffer = { role: update.role, messageId: update.messageId, text: update.text };
|
|
180
|
+
}
|
|
181
|
+
#flushMessage() {
|
|
182
|
+
const buffer = this.#buffer;
|
|
183
|
+
if (buffer === undefined)
|
|
184
|
+
return;
|
|
185
|
+
this.#buffer = undefined;
|
|
186
|
+
const { id, parentId } = this.#nextIdentity();
|
|
187
|
+
switch (buffer.role) {
|
|
188
|
+
case "assistant":
|
|
189
|
+
this.#push({ type: "assistant", id, parentId, text: buffer.text });
|
|
190
|
+
return;
|
|
191
|
+
case "reasoning":
|
|
192
|
+
this.#push({ type: "reasoning", id, parentId, text: buffer.text });
|
|
193
|
+
return;
|
|
194
|
+
case "user":
|
|
195
|
+
this.#push({ type: "user", id, parentId, text: buffer.text });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
#nextIdentity() {
|
|
200
|
+
const id = this.#newEventId();
|
|
201
|
+
const parentId = this.#lastId;
|
|
202
|
+
this.#lastId = id;
|
|
203
|
+
return { id, parentId };
|
|
204
|
+
}
|
|
205
|
+
#push(event) {
|
|
206
|
+
this.#sink.emit(event);
|
|
207
|
+
this.#collector?.push(event);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Two message chunks belong to the same logical message when their `messageId`s
|
|
212
|
+
* are equal, or when neither carries one (the agent omitted it) — in which case
|
|
213
|
+
* consecutive same-role chunks coalesce until a role change or a tool boundary.
|
|
214
|
+
*/
|
|
215
|
+
function sameMessage(a, b) {
|
|
216
|
+
if (a === null && b === null)
|
|
217
|
+
return true;
|
|
218
|
+
return a === b;
|
|
219
|
+
}
|
|
220
|
+
function readStopReason(result) {
|
|
221
|
+
if (isRecord(result) && typeof result.stopReason === "string")
|
|
222
|
+
return result.stopReason;
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Select the first option whose kind reads as an approval from a
|
|
227
|
+
* `session/request_permission` request, so a driven session proceeds without a
|
|
228
|
+
* human in the loop; cancel when the agent offers no allow option. Full
|
|
229
|
+
* interactive-permission and client-side `fs`/`terminal` support is out of this
|
|
230
|
+
* slice's scope (this backend advertises neither capability at `initialize`).
|
|
231
|
+
*/
|
|
232
|
+
function defaultPermissionResponse(params) {
|
|
233
|
+
if (isRecord(params) && Array.isArray(params.options)) {
|
|
234
|
+
for (const option of params.options) {
|
|
235
|
+
if (!isRecord(option))
|
|
236
|
+
continue;
|
|
237
|
+
const kind = option.kind;
|
|
238
|
+
if (typeof kind === "string" && kind.startsWith("allow") && typeof option.optionId === "string") {
|
|
239
|
+
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// No "allow"-flavoured option (or no options at all): cancel, as documented,
|
|
244
|
+
// rather than silently selecting an arbitrary — possibly deny — option.
|
|
245
|
+
return { outcome: { outcome: "cancelled" } };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Open an ACP ingestion client over `transport`, emitting into `sink`. Wraps the
|
|
249
|
+
* transport in an {@link AcpConnection} and returns the ready client; call
|
|
250
|
+
* {@link AcpSessionClient.initialize} first.
|
|
251
|
+
*/
|
|
252
|
+
export function openAcpSession(transport, sink, options = {}) {
|
|
253
|
+
return new AcpSessionClient(new AcpConnection(transport), sink, options);
|
|
254
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nanobpm/agentic/session/acp` — the ACP ingestion backend (ADR 0062, slice 2).
|
|
3
|
+
*
|
|
4
|
+
* The preferred harness-facing backend of the `@nanobpm/agentic/session` contract
|
|
5
|
+
* (ADR 0062 §5): an Agent Client Protocol (protocol v1) client that connects to an
|
|
6
|
+
* ACP-speaking harness (`opencode acp`, `claude-code-acp`, the Gemini lineage),
|
|
7
|
+
* runs the `initialize` handshake to probe `agentCapabilities.loadSession`, and
|
|
8
|
+
* normalises the `session/update` stream into canonical {@link SessionEvent}s —
|
|
9
|
+
* driving via `session/prompt`, steering via `session/cancel`, and **restoring**
|
|
10
|
+
* prior history via `session/load`.
|
|
11
|
+
*
|
|
12
|
+
* Exports, in the order a consumer meets them:
|
|
13
|
+
* - {@link openAcpSession} / {@link AcpSessionClient} — the ingestion client and
|
|
14
|
+
* its capability probe / prompt-result types;
|
|
15
|
+
* - {@link classifyUpdate} + {@link ACP_FIDELITY_GAPS} — the pure wire→canonical
|
|
16
|
+
* normaliser and the enumerated ADR 0062 §5 fidelity gaps that justify slice 3;
|
|
17
|
+
* - the {@link AcpConnection} JSON-RPC peer and the {@link AcpTransport} seam,
|
|
18
|
+
* with {@link spawnAcpTransport} (a real harness subprocess) and
|
|
19
|
+
* {@link inMemoryTransportPair} (an in-memory peer for tests);
|
|
20
|
+
* - the ACP wire vocabulary ({@link ACP_METHOD}, guards) from `protocol.ts`.
|
|
21
|
+
*/
|
|
22
|
+
export { AcpSessionClient, type AcpCapabilityProbe, type AcpPromptInput, type AcpPromptResult, type AcpSessionClientOptions, type AcpSessionParams, type AcpTextContentBlock, openAcpSession, type SessionEventSink, } from "./client.ts";
|
|
23
|
+
export { ACP_FIDELITY_GAPS, type AcpClassifiedUpdate, type AcpFidelityGap, classifyUpdate, } from "./normalize.ts";
|
|
24
|
+
export { AcpConnection, type AcpNotificationHandler, type AcpRequestHandler, AcpRpcError, } from "./jsonrpc.ts";
|
|
25
|
+
export { type AcpTransport, encodeMessageLine, inMemoryTransportPair, NewlineJsonDecoder, } from "./transport.ts";
|
|
26
|
+
export { type SpawnAcpOptions, type SpawnedAcpTransport, spawnAcpTransport } from "./spawn.ts";
|
|
27
|
+
export { ACP_CLIENT_METHOD, ACP_METHOD, ACP_PROTOCOL_VERSION, type AcpAgentCapabilities, type AcpInitializeResult, type AcpPromptCapabilities, AcpProtocolError, contentBlockText, parseInitializeResult, parseSessionId, } from "./protocol.ts";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nanobpm/agentic/session/acp` — the ACP ingestion backend (ADR 0062, slice 2).
|
|
3
|
+
*
|
|
4
|
+
* The preferred harness-facing backend of the `@nanobpm/agentic/session` contract
|
|
5
|
+
* (ADR 0062 §5): an Agent Client Protocol (protocol v1) client that connects to an
|
|
6
|
+
* ACP-speaking harness (`opencode acp`, `claude-code-acp`, the Gemini lineage),
|
|
7
|
+
* runs the `initialize` handshake to probe `agentCapabilities.loadSession`, and
|
|
8
|
+
* normalises the `session/update` stream into canonical {@link SessionEvent}s —
|
|
9
|
+
* driving via `session/prompt`, steering via `session/cancel`, and **restoring**
|
|
10
|
+
* prior history via `session/load`.
|
|
11
|
+
*
|
|
12
|
+
* Exports, in the order a consumer meets them:
|
|
13
|
+
* - {@link openAcpSession} / {@link AcpSessionClient} — the ingestion client and
|
|
14
|
+
* its capability probe / prompt-result types;
|
|
15
|
+
* - {@link classifyUpdate} + {@link ACP_FIDELITY_GAPS} — the pure wire→canonical
|
|
16
|
+
* normaliser and the enumerated ADR 0062 §5 fidelity gaps that justify slice 3;
|
|
17
|
+
* - the {@link AcpConnection} JSON-RPC peer and the {@link AcpTransport} seam,
|
|
18
|
+
* with {@link spawnAcpTransport} (a real harness subprocess) and
|
|
19
|
+
* {@link inMemoryTransportPair} (an in-memory peer for tests);
|
|
20
|
+
* - the ACP wire vocabulary ({@link ACP_METHOD}, guards) from `protocol.ts`.
|
|
21
|
+
*/
|
|
22
|
+
export { AcpSessionClient, openAcpSession, } from "./client.js";
|
|
23
|
+
export { ACP_FIDELITY_GAPS, classifyUpdate, } from "./normalize.js";
|
|
24
|
+
export { AcpConnection, AcpRpcError, } from "./jsonrpc.js";
|
|
25
|
+
export { encodeMessageLine, inMemoryTransportPair, NewlineJsonDecoder, } from "./transport.js";
|
|
26
|
+
export { spawnAcpTransport } from "./spawn.js";
|
|
27
|
+
export { ACP_CLIENT_METHOD, ACP_METHOD, ACP_PROTOCOL_VERSION, AcpProtocolError, contentBlockText, parseInitializeResult, parseSessionId, } from "./protocol.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AcpTransport } from "./transport.ts";
|
|
2
|
+
/** A handler for an inbound agent→client request; its resolved value is the result. */
|
|
3
|
+
export type AcpRequestHandler = (params: unknown) => unknown | Promise<unknown>;
|
|
4
|
+
/** A handler for an inbound agent→client notification (no response). */
|
|
5
|
+
export type AcpNotificationHandler = (params: unknown) => void;
|
|
6
|
+
/** A JSON-RPC error returned by, or raised toward, the peer. */
|
|
7
|
+
export declare class AcpRpcError extends Error {
|
|
8
|
+
readonly code: number;
|
|
9
|
+
readonly data: unknown;
|
|
10
|
+
constructor(code: number, message: string, data?: unknown);
|
|
11
|
+
}
|
|
12
|
+
export declare class AcpConnection {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(transport: AcpTransport);
|
|
15
|
+
/** Send a request and resolve with its `result` (or reject with its `error`). */
|
|
16
|
+
request(method: string, params?: unknown): Promise<unknown>;
|
|
17
|
+
/** Send a fire-and-forget notification (no id, no response). */
|
|
18
|
+
notify(method: string, params?: unknown): void;
|
|
19
|
+
/** Register the handler for an inbound notification `method` (last wins). */
|
|
20
|
+
onNotification(method: string, handler: AcpNotificationHandler): void;
|
|
21
|
+
/** Register the handler for an inbound request `method` (last wins). */
|
|
22
|
+
onRequest(method: string, handler: AcpRequestHandler): void;
|
|
23
|
+
/** Close the connection, rejecting every in-flight request. */
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal JSON-RPC 2.0 peer for ACP — ADR 0062, slice 2.
|
|
3
|
+
*
|
|
4
|
+
* ACP is symmetric: the client sends requests/notifications to the agent
|
|
5
|
+
* (`initialize`, `session/*`) and the agent sends requests/notifications back to
|
|
6
|
+
* the client (`session/update`, `session/request_permission`). {@link AcpConnection}
|
|
7
|
+
* is the bidirectional peer over an {@link AcpTransport}: it correlates responses
|
|
8
|
+
* to outbound requests by id, dispatches inbound notifications and requests to
|
|
9
|
+
* registered handlers, and answers an unhandled inbound request with a
|
|
10
|
+
* JSON-RPC "method not found" instead of hanging the agent.
|
|
11
|
+
*
|
|
12
|
+
* It is intentionally tiny and dependency-free — the repo has no JSON-RPC library
|
|
13
|
+
* and this is the only surface that needs one, so a focused peer beats pulling a
|
|
14
|
+
* general framework (and avoids a drift surface).
|
|
15
|
+
*/
|
|
16
|
+
import { isRecord } from "./protocol.js";
|
|
17
|
+
/** A JSON-RPC error returned by, or raised toward, the peer. */
|
|
18
|
+
export class AcpRpcError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
data;
|
|
21
|
+
constructor(code, message, data) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "AcpRpcError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.data = data;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const METHOD_NOT_FOUND = -32601;
|
|
29
|
+
const INTERNAL_ERROR = -32603;
|
|
30
|
+
export class AcpConnection {
|
|
31
|
+
#transport;
|
|
32
|
+
#pending = new Map();
|
|
33
|
+
#notificationHandlers = new Map();
|
|
34
|
+
#requestHandlers = new Map();
|
|
35
|
+
#nextId = 1;
|
|
36
|
+
#closed = false;
|
|
37
|
+
constructor(transport) {
|
|
38
|
+
this.#transport = transport;
|
|
39
|
+
transport.onMessage((message) => this.#dispatch(message));
|
|
40
|
+
transport.onError((error) => this.#failAll(error));
|
|
41
|
+
}
|
|
42
|
+
/** Send a request and resolve with its `result` (or reject with its `error`). */
|
|
43
|
+
request(method, params) {
|
|
44
|
+
if (this.#closed) {
|
|
45
|
+
return Promise.reject(new Error(`ACP connection is closed; cannot call ${method}`));
|
|
46
|
+
}
|
|
47
|
+
const id = this.#nextId++;
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
this.#pending.set(id, { resolve, reject });
|
|
50
|
+
this.#transport.send({ jsonrpc: "2.0", id, method, params });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Send a fire-and-forget notification (no id, no response). */
|
|
54
|
+
notify(method, params) {
|
|
55
|
+
if (this.#closed)
|
|
56
|
+
return;
|
|
57
|
+
this.#transport.send({ jsonrpc: "2.0", method, params });
|
|
58
|
+
}
|
|
59
|
+
/** Register the handler for an inbound notification `method` (last wins). */
|
|
60
|
+
onNotification(method, handler) {
|
|
61
|
+
this.#notificationHandlers.set(method, handler);
|
|
62
|
+
}
|
|
63
|
+
/** Register the handler for an inbound request `method` (last wins). */
|
|
64
|
+
onRequest(method, handler) {
|
|
65
|
+
this.#requestHandlers.set(method, handler);
|
|
66
|
+
}
|
|
67
|
+
/** Close the connection, rejecting every in-flight request. */
|
|
68
|
+
close() {
|
|
69
|
+
if (this.#closed)
|
|
70
|
+
return;
|
|
71
|
+
this.#failAll(new Error("ACP connection closed"));
|
|
72
|
+
this.#closed = true;
|
|
73
|
+
this.#transport.close();
|
|
74
|
+
}
|
|
75
|
+
#dispatch(message) {
|
|
76
|
+
if (!isRecord(message))
|
|
77
|
+
return;
|
|
78
|
+
const hasId = "id" in message && (typeof message.id === "number" || typeof message.id === "string");
|
|
79
|
+
const isResponse = hasId && ("result" in message || "error" in message);
|
|
80
|
+
if (isResponse) {
|
|
81
|
+
this.#handleResponse(message);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (typeof message.method === "string") {
|
|
85
|
+
if (hasId) {
|
|
86
|
+
this.#handleInboundRequest(message.method, message.id, message.params);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
this.#handleInboundNotification(message.method, message.params);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
#handleResponse(message) {
|
|
94
|
+
const id = message.id;
|
|
95
|
+
if (typeof id !== "number")
|
|
96
|
+
return; // we only ever issue numeric ids
|
|
97
|
+
const pending = this.#pending.get(id);
|
|
98
|
+
if (pending === undefined)
|
|
99
|
+
return;
|
|
100
|
+
this.#pending.delete(id);
|
|
101
|
+
if ("error" in message && message.error !== undefined && message.error !== null) {
|
|
102
|
+
pending.reject(toRpcError(message.error));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
pending.resolve("result" in message ? message.result : undefined);
|
|
106
|
+
}
|
|
107
|
+
#handleInboundNotification(method, params) {
|
|
108
|
+
const handler = this.#notificationHandlers.get(method);
|
|
109
|
+
if (handler !== undefined)
|
|
110
|
+
handler(params);
|
|
111
|
+
}
|
|
112
|
+
#handleInboundRequest(method, id, params) {
|
|
113
|
+
const handler = this.#requestHandlers.get(method);
|
|
114
|
+
if (handler === undefined) {
|
|
115
|
+
this.#transport.send({
|
|
116
|
+
jsonrpc: "2.0",
|
|
117
|
+
id,
|
|
118
|
+
error: { code: METHOD_NOT_FOUND, message: `method not found: ${method}` },
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
Promise.resolve()
|
|
123
|
+
.then(() => handler(params))
|
|
124
|
+
.then((result) => this.#transport.send({ jsonrpc: "2.0", id, result: result ?? null }), (reason) => this.#transport.send({ jsonrpc: "2.0", id, error: errorPayload(reason) }));
|
|
125
|
+
}
|
|
126
|
+
#failAll(error) {
|
|
127
|
+
for (const pending of this.#pending.values())
|
|
128
|
+
pending.reject(error);
|
|
129
|
+
this.#pending.clear();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function toRpcError(error) {
|
|
133
|
+
if (isRecord(error)) {
|
|
134
|
+
const code = typeof error.code === "number" ? error.code : INTERNAL_ERROR;
|
|
135
|
+
const message = typeof error.message === "string" ? error.message : "ACP request failed";
|
|
136
|
+
return new AcpRpcError(code, message, error.data);
|
|
137
|
+
}
|
|
138
|
+
return new AcpRpcError(INTERNAL_ERROR, "ACP request failed with a non-object error");
|
|
139
|
+
}
|
|
140
|
+
function errorPayload(reason) {
|
|
141
|
+
if (reason instanceof AcpRpcError) {
|
|
142
|
+
return { code: reason.code, message: reason.message, data: reason.data };
|
|
143
|
+
}
|
|
144
|
+
if (reason instanceof Error) {
|
|
145
|
+
return { code: INTERNAL_ERROR, message: reason.message };
|
|
146
|
+
}
|
|
147
|
+
return { code: INTERNAL_ERROR, message: String(reason) };
|
|
148
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outcome of classifying one ACP `session/update`. A `message` still needs
|
|
3
|
+
* its chunks coalesced by the client; a `tool-call`/`tool-result` is a complete
|
|
4
|
+
* canonical event body; an `ignored` update carries no canonical meaning.
|
|
5
|
+
*/
|
|
6
|
+
export type AcpClassifiedUpdate = {
|
|
7
|
+
readonly kind: "message";
|
|
8
|
+
readonly role: "assistant" | "reasoning" | "user";
|
|
9
|
+
/** Groups streamed chunks into one message; `null` when the agent omits it. */
|
|
10
|
+
readonly messageId: string | null;
|
|
11
|
+
readonly text: string;
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: "tool-call";
|
|
14
|
+
readonly callId: string;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly args: unknown;
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: "tool-result";
|
|
19
|
+
readonly callId: string;
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
readonly result: unknown;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: "ignored";
|
|
24
|
+
readonly sessionUpdate: string;
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Classify one ACP `update` object (the `params.update` of a `session/update`
|
|
29
|
+
* notification). Pure and total — every input yields a classification, malformed
|
|
30
|
+
* or unknown ones landing in `ignored` with a diagnostic reason rather than
|
|
31
|
+
* throwing, so a single odd notification can never abort an ingestion stream.
|
|
32
|
+
*/
|
|
33
|
+
export declare function classifyUpdate(value: unknown): AcpClassifiedUpdate;
|
|
34
|
+
/** One place where ACP is thinner than a native transcript (ADR 0062 §5). */
|
|
35
|
+
export interface AcpFidelityGap {
|
|
36
|
+
/** The canonical concept that cannot be fully reconstructed from ACP alone. */
|
|
37
|
+
readonly concept: string;
|
|
38
|
+
/** Why ACP cannot carry it, and what a native transcript (slice 3) preserves. */
|
|
39
|
+
readonly detail: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The enumerated resume-fidelity gaps in the ACP backend — the documented reason
|
|
43
|
+
* (ADR 0062 §5) the slice 3 native normaliser exists as a fallback path. This is
|
|
44
|
+
* a derived source of truth: `normalize.test.ts` asserts the mapping above only
|
|
45
|
+
* ever emits `assistant`/`reasoning`/`user`/`tool-call`/`tool-result`, so any
|
|
46
|
+
* canonical event ACP *cannot* produce is accounted for here.
|
|
47
|
+
*/
|
|
48
|
+
export declare const ACP_FIDELITY_GAPS: readonly AcpFidelityGap[];
|