@nebutra/agent-runtime 0.2.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/.turbo/turbo-build.log +115 -0
- package/.turbo/turbo-test.log +44 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +253 -0
- package/LICENSE +676 -0
- package/README.md +50 -0
- package/dist/adapters/dispatcher-sse.d.ts +68 -0
- package/dist/adapters/dispatcher-sse.js +11 -0
- package/dist/adapters/dispatcher-sse.js.map +1 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/mcp-catalog.d.ts +58 -0
- package/dist/adapters/mcp-catalog.js +9 -0
- package/dist/adapters/mcp-catalog.js.map +1 -0
- package/dist/adapters/prisma-rollout.d.ts +60 -0
- package/dist/adapters/prisma-rollout.js +7 -0
- package/dist/adapters/prisma-rollout.js.map +1 -0
- package/dist/chunk-24ZXP7FI.js +93 -0
- package/dist/chunk-24ZXP7FI.js.map +1 -0
- package/dist/chunk-2DA6Q6TN.js +126 -0
- package/dist/chunk-2DA6Q6TN.js.map +1 -0
- package/dist/chunk-37BBB2P2.js +73 -0
- package/dist/chunk-37BBB2P2.js.map +1 -0
- package/dist/chunk-57W3AR43.js +52 -0
- package/dist/chunk-57W3AR43.js.map +1 -0
- package/dist/chunk-5N4644PB.js +67 -0
- package/dist/chunk-5N4644PB.js.map +1 -0
- package/dist/chunk-5YS7WAPS.js +177 -0
- package/dist/chunk-5YS7WAPS.js.map +1 -0
- package/dist/chunk-6EGG2OZC.js +13 -0
- package/dist/chunk-6EGG2OZC.js.map +1 -0
- package/dist/chunk-7BUOF367.js +126 -0
- package/dist/chunk-7BUOF367.js.map +1 -0
- package/dist/chunk-BJBBR3QA.js +121 -0
- package/dist/chunk-BJBBR3QA.js.map +1 -0
- package/dist/chunk-CGRCUKGT.js +73 -0
- package/dist/chunk-CGRCUKGT.js.map +1 -0
- package/dist/chunk-FUG5DT2C.js +75 -0
- package/dist/chunk-FUG5DT2C.js.map +1 -0
- package/dist/chunk-LO24VOA3.js +199 -0
- package/dist/chunk-LO24VOA3.js.map +1 -0
- package/dist/chunk-MUF7ZZTO.js +57 -0
- package/dist/chunk-MUF7ZZTO.js.map +1 -0
- package/dist/chunk-NN7DATXA.js +46 -0
- package/dist/chunk-NN7DATXA.js.map +1 -0
- package/dist/chunk-PGGWSUTM.js +33 -0
- package/dist/chunk-PGGWSUTM.js.map +1 -0
- package/dist/chunk-RDKYDMXT.js +135 -0
- package/dist/chunk-RDKYDMXT.js.map +1 -0
- package/dist/chunk-YYFPDBJG.js +63 -0
- package/dist/chunk-YYFPDBJG.js.map +1 -0
- package/dist/chunk-ZMYX5VBU.js +135 -0
- package/dist/chunk-ZMYX5VBU.js.map +1 -0
- package/dist/chunk-ZTSKS42I.js +131 -0
- package/dist/chunk-ZTSKS42I.js.map +1 -0
- package/dist/commands.d.ts +74 -0
- package/dist/commands.js +10 -0
- package/dist/commands.js.map +1 -0
- package/dist/definitions.d.ts +94 -0
- package/dist/definitions.js +15 -0
- package/dist/definitions.js.map +1 -0
- package/dist/dispatcher.d.ts +50 -0
- package/dist/dispatcher.js +8 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/durable-turn.d.ts +58 -0
- package/dist/durable-turn.js +9 -0
- package/dist/durable-turn.js.map +1 -0
- package/dist/hook-pipeline.d.ts +114 -0
- package/dist/hook-pipeline.js +13 -0
- package/dist/hook-pipeline.js.map +1 -0
- package/dist/index.d.ts +1874 -0
- package/dist/index.js +3117 -0
- package/dist/index.js.map +1 -0
- package/dist/loop.d.ts +78 -0
- package/dist/loop.js +9 -0
- package/dist/loop.js.map +1 -0
- package/dist/mcp-bridge.d.ts +48 -0
- package/dist/mcp-bridge.js +8 -0
- package/dist/mcp-bridge.js.map +1 -0
- package/dist/model.d.ts +154 -0
- package/dist/model.js +9 -0
- package/dist/model.js.map +1 -0
- package/dist/policy.d.ts +130 -0
- package/dist/policy.js +23 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +170 -0
- package/dist/protocol.js +15 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rollout-store-persistent.d.ts +48 -0
- package/dist/rollout-store-persistent.js +9 -0
- package/dist/rollout-store-persistent.js.map +1 -0
- package/dist/rollout.d.ts +82 -0
- package/dist/rollout.js +15 -0
- package/dist/rollout.js.map +1 -0
- package/dist/sandbox.d.ts +65 -0
- package/dist/sandbox.js +15 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/skills.d.ts +93 -0
- package/dist/skills.js +10 -0
- package/dist/skills.js.map +1 -0
- package/dist/subagents.d.ts +129 -0
- package/dist/subagents.js +21 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +77 -0
- package/dist/tools.js +9 -0
- package/dist/tools.js.map +1 -0
- package/package.json +74 -0
- package/src/adapters/dispatcher-sse.test.ts +218 -0
- package/src/adapters/dispatcher-sse.ts +222 -0
- package/src/adapters/index.ts +18 -0
- package/src/adapters/mcp-catalog.test.ts +213 -0
- package/src/adapters/mcp-catalog.ts +188 -0
- package/src/adapters/prisma-rollout.test.ts +153 -0
- package/src/adapters/prisma-rollout.ts +104 -0
- package/src/agent-runtime.test.ts +176 -0
- package/src/artifact-stream.test.ts +330 -0
- package/src/artifact-stream.ts +453 -0
- package/src/channel-gateway.test.ts +432 -0
- package/src/channel-gateway.ts +357 -0
- package/src/code-review.test.ts +501 -0
- package/src/code-review.ts +495 -0
- package/src/command-suggestions.test.ts +251 -0
- package/src/command-suggestions.ts +338 -0
- package/src/commands.test.ts +184 -0
- package/src/commands.ts +140 -0
- package/src/commit-message.test.ts +249 -0
- package/src/commit-message.ts +180 -0
- package/src/context-compaction.test.ts +522 -0
- package/src/context-compaction.ts +434 -0
- package/src/definitions.test.ts +78 -0
- package/src/definitions.ts +190 -0
- package/src/deployment-status.test.ts +215 -0
- package/src/deployment-status.ts +227 -0
- package/src/design-context.test.ts +195 -0
- package/src/design-context.ts +198 -0
- package/src/dispatcher.test.ts +234 -0
- package/src/dispatcher.ts +189 -0
- package/src/durable-turn.test.ts +209 -0
- package/src/durable-turn.ts +135 -0
- package/src/edit-planner.test.ts +204 -0
- package/src/edit-planner.ts +325 -0
- package/src/fuzzy-match.test.ts +311 -0
- package/src/fuzzy-match.ts +444 -0
- package/src/hook-pipeline.test.ts +279 -0
- package/src/hook-pipeline.ts +373 -0
- package/src/inbound-admission.test.ts +394 -0
- package/src/inbound-admission.ts +246 -0
- package/src/index.ts +47 -0
- package/src/loop.test.ts +161 -0
- package/src/loop.ts +211 -0
- package/src/mcp-bridge.test.ts +165 -0
- package/src/mcp-bridge.ts +76 -0
- package/src/memory-provider.test.ts +232 -0
- package/src/memory-provider.ts +257 -0
- package/src/model.ts +168 -0
- package/src/permission-ruleset.test.ts +301 -0
- package/src/permission-ruleset.ts +200 -0
- package/src/policy.ts +151 -0
- package/src/project-repo.test.ts +232 -0
- package/src/project-repo.ts +311 -0
- package/src/protocol.ts +159 -0
- package/src/rollout-store-persistent.test.ts +217 -0
- package/src/rollout-store-persistent.ts +166 -0
- package/src/rollout.ts +150 -0
- package/src/sandbox.ts +113 -0
- package/src/session-share.test.ts +360 -0
- package/src/session-share.ts +310 -0
- package/src/skill-distillation.test.ts +177 -0
- package/src/skill-distillation.ts +369 -0
- package/src/skills.test.ts +277 -0
- package/src/skills.ts +255 -0
- package/src/subagents.test.ts +290 -0
- package/src/subagents.ts +332 -0
- package/src/tools.ts +126 -0
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +33 -0
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nebutra/agent-runtime",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"nebutra": {
|
|
8
|
+
"status": "wip",
|
|
9
|
+
"productionReady": false,
|
|
10
|
+
"requires": [
|
|
11
|
+
"@nebutra/agents for model execution",
|
|
12
|
+
"@nebutra/tenant for tenant scoping"
|
|
13
|
+
],
|
|
14
|
+
"gaps": [
|
|
15
|
+
"Track-B kernel sidecar transport not yet wired",
|
|
16
|
+
"Durable-turn queue binding is interface-only",
|
|
17
|
+
"Rollout store backed by in-memory reference impl only"
|
|
18
|
+
],
|
|
19
|
+
"featureId": "agent-runtime",
|
|
20
|
+
"category": "ai",
|
|
21
|
+
"summary": "Multi-tenant agent-runtime grammar: thread/turn/item model, approval+capability policy, tool/MCP abstraction, event-sourced rollout, external-sandbox delegation seam"
|
|
22
|
+
},
|
|
23
|
+
"main": "./src/index.ts",
|
|
24
|
+
"types": "./src/index.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.ts",
|
|
27
|
+
"./model": "./src/model.ts",
|
|
28
|
+
"./protocol": "./src/protocol.ts",
|
|
29
|
+
"./policy": "./src/policy.ts",
|
|
30
|
+
"./tools": "./src/tools.ts",
|
|
31
|
+
"./rollout": "./src/rollout.ts",
|
|
32
|
+
"./sandbox": "./src/sandbox.ts",
|
|
33
|
+
"./adapters": "./src/adapters/index.ts",
|
|
34
|
+
"./adapters/mcp-catalog": "./src/adapters/mcp-catalog.ts",
|
|
35
|
+
"./adapters/dispatcher-sse": "./src/adapters/dispatcher-sse.ts",
|
|
36
|
+
"./adapters/prisma-rollout": "./src/adapters/prisma-rollout.ts"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"zod": "^4.3.6"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@nebutra/mcp": "0.1.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@nebutra/mcp": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.19.15",
|
|
51
|
+
"tsup": "^8.5.1",
|
|
52
|
+
"typescript": "^5.9.3",
|
|
53
|
+
"vitest": "^4.0.18",
|
|
54
|
+
"@nebutra/mcp": "0.1.1"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/Nebutra/Nebutra-Sailor/tree/main/packages/ai/agent-runtime#readme",
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/Nebutra/Nebutra-Sailor.git",
|
|
60
|
+
"directory": "packages/ai/agent-runtime"
|
|
61
|
+
},
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/Nebutra/Nebutra-Sailor/issues"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "tsup",
|
|
70
|
+
"dev": "tsup --watch",
|
|
71
|
+
"test": "vitest run",
|
|
72
|
+
"typecheck": "tsc --noEmit"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-standard transport binding tests — no server, no framework.
|
|
3
|
+
*
|
|
4
|
+
* Every test constructs a `new Request(...)` and inspects a `Response` via
|
|
5
|
+
* Web-standard APIs only (`response.text()`, `response.body!.getReader()`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { ProtocolDispatcher } from "../dispatcher";
|
|
10
|
+
import type { ThreadEvent } from "../model";
|
|
11
|
+
import { createRpcHandler, sseResponse, subscribeNotifications } from "./dispatcher-sse.js";
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** A dispatcher with one echo handler bound to a real registered method. */
|
|
16
|
+
function freshDispatcher(): ProtocolDispatcher {
|
|
17
|
+
const d = new ProtocolDispatcher();
|
|
18
|
+
// `thread/start` is in the method registry; echo its params back.
|
|
19
|
+
d.register("thread/start", async (params) => ({ echoed: params }));
|
|
20
|
+
return d;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function jsonRequest(body: unknown): Request {
|
|
24
|
+
return new Request("https://edge.test/rpc", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Drain an SSE Response body into its raw decoded text. */
|
|
32
|
+
async function drainSse(res: Response): Promise<string> {
|
|
33
|
+
if (!res.body) throw new Error("SSE response body is missing");
|
|
34
|
+
const reader = res.body.getReader();
|
|
35
|
+
const decoder = new TextDecoder();
|
|
36
|
+
let out = "";
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { value, done } = await reader.read();
|
|
39
|
+
if (done) break;
|
|
40
|
+
out += decoder.decode(value, { stream: true });
|
|
41
|
+
}
|
|
42
|
+
out += decoder.decode();
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function* eventsOf(...events: ThreadEvent[]): AsyncIterable<ThreadEvent> {
|
|
47
|
+
for (const e of events) yield e;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── createRpcHandler ─────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("createRpcHandler", () => {
|
|
53
|
+
it("returns a JSON-RPC success envelope (200, application/json) on happy path", async () => {
|
|
54
|
+
const handler = createRpcHandler(freshDispatcher());
|
|
55
|
+
const res = await handler(
|
|
56
|
+
jsonRequest({
|
|
57
|
+
jsonrpc: "2.0",
|
|
58
|
+
id: 1,
|
|
59
|
+
method: "thread/start",
|
|
60
|
+
tenantId: "org_123",
|
|
61
|
+
params: { hello: "world" },
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
67
|
+
const body = await res.json();
|
|
68
|
+
expect(body).toEqual({
|
|
69
|
+
jsonrpc: "2.0",
|
|
70
|
+
id: 1,
|
|
71
|
+
result: { echoed: { hello: "world" } },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns a well-formed JSON-RPC parse error (400) on malformed JSON — never throws", async () => {
|
|
76
|
+
const handler = createRpcHandler(freshDispatcher());
|
|
77
|
+
const res = await handler(jsonRequest("{ not valid json"));
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(400);
|
|
80
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
81
|
+
const body = await res.json();
|
|
82
|
+
expect(body.jsonrpc).toBe("2.0");
|
|
83
|
+
expect(body.id).toBeNull();
|
|
84
|
+
expect(body.error.code).toBe(-32700);
|
|
85
|
+
expect(typeof body.error.message).toBe("string");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("fails closed on a missing-tenant envelope (200 JSON-RPC business error)", async () => {
|
|
89
|
+
const handler = createRpcHandler(freshDispatcher());
|
|
90
|
+
const res = await handler(
|
|
91
|
+
jsonRequest({ jsonrpc: "2.0", id: 7, method: "thread/start", params: {} }),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Envelope was parseable JSON → 200, but dispatch fails closed.
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
const body = await res.json();
|
|
97
|
+
expect(body.jsonrpc).toBe("2.0");
|
|
98
|
+
expect(body.error).toBeDefined();
|
|
99
|
+
expect(body.error.code).toBe(-32600);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns method-not-found JSON-RPC error for an unknown method", async () => {
|
|
103
|
+
const handler = createRpcHandler(freshDispatcher());
|
|
104
|
+
const res = await handler(
|
|
105
|
+
jsonRequest({ jsonrpc: "2.0", id: 9, method: "totally/unknown", tenantId: "org_1" }),
|
|
106
|
+
);
|
|
107
|
+
expect(res.status).toBe(200);
|
|
108
|
+
const body = await res.json();
|
|
109
|
+
expect(body.error.code).toBe(-32601);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── sseResponse ──────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("sseResponse", () => {
|
|
116
|
+
it("sets text/event-stream + no-cache headers", () => {
|
|
117
|
+
const res = sseResponse(eventsOf({ type: "turn.started" }));
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
|
120
|
+
expect(res.headers.get("cache-control")).toContain("no-cache");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("streams multiple events in order with correct SSE framing", async () => {
|
|
124
|
+
const stream = eventsOf(
|
|
125
|
+
{ type: "thread.started", threadId: "t1" },
|
|
126
|
+
{ type: "turn.started" },
|
|
127
|
+
{
|
|
128
|
+
type: "turn.completed",
|
|
129
|
+
usage: { inputTokens: 1, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0 },
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
const text = await drainSse(sseResponse(stream));
|
|
133
|
+
|
|
134
|
+
const frames = text.split("\n\n").filter(Boolean);
|
|
135
|
+
expect(frames).toHaveLength(3);
|
|
136
|
+
expect(frames[0]).toBe(
|
|
137
|
+
`event: thread.started\ndata: ${JSON.stringify({ type: "thread.started", threadId: "t1" })}`,
|
|
138
|
+
);
|
|
139
|
+
expect(frames[1]).toBe(
|
|
140
|
+
`event: turn.started\ndata: ${JSON.stringify({ type: "turn.started" })}`,
|
|
141
|
+
);
|
|
142
|
+
expect((frames[2] ?? "").startsWith("event: turn.completed\n")).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("surfaces a mid-stream iterable error as a final `event: error` frame then closes", async () => {
|
|
146
|
+
async function* boom(): AsyncIterable<ThreadEvent> {
|
|
147
|
+
yield { type: "turn.started" };
|
|
148
|
+
throw new Error("upstream exploded");
|
|
149
|
+
}
|
|
150
|
+
const text = await drainSse(sseResponse(boom()));
|
|
151
|
+
const frames = text.split("\n\n").filter(Boolean);
|
|
152
|
+
|
|
153
|
+
expect(frames[0]).toBe(
|
|
154
|
+
`event: turn.started\ndata: ${JSON.stringify({ type: "turn.started" })}`,
|
|
155
|
+
);
|
|
156
|
+
const last = frames.at(-1) ?? "";
|
|
157
|
+
expect(last.startsWith("event: error\n")).toBe(true);
|
|
158
|
+
expect(last).toContain("upstream exploded");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("cancels promptly when the provided AbortSignal aborts", async () => {
|
|
162
|
+
const ac = new AbortController();
|
|
163
|
+
let produced = 0;
|
|
164
|
+
async function* infinite(): AsyncIterable<ThreadEvent> {
|
|
165
|
+
for (;;) {
|
|
166
|
+
produced += 1;
|
|
167
|
+
yield { type: "turn.started" };
|
|
168
|
+
if (produced === 2) ac.abort();
|
|
169
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const res = sseResponse(infinite(), { signal: ac.signal });
|
|
173
|
+
const text = await drainSse(res);
|
|
174
|
+
// Stream terminated; did not run forever.
|
|
175
|
+
expect(text.length).toBeGreaterThan(0);
|
|
176
|
+
expect(produced).toBeLessThan(50);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("does not throw when aborted before the first read", async () => {
|
|
180
|
+
const ac = new AbortController();
|
|
181
|
+
ac.abort();
|
|
182
|
+
const res = sseResponse(eventsOf({ type: "turn.started" }), { signal: ac.signal });
|
|
183
|
+
const text = await drainSse(res);
|
|
184
|
+
expect(typeof text).toBe("string");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── subscribeNotifications ───────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
describe("subscribeNotifications", () => {
|
|
191
|
+
it("bridges dispatcher notifications into an async iterable", async () => {
|
|
192
|
+
const d = new ProtocolDispatcher();
|
|
193
|
+
const iter = subscribeNotifications(d, ["turn/started", "turn/completed"]);
|
|
194
|
+
const reader = iter[Symbol.asyncIterator]();
|
|
195
|
+
|
|
196
|
+
queueMicrotask(() => {
|
|
197
|
+
d.emitNotification("turn/started", { a: 1 });
|
|
198
|
+
d.emitNotification("turn/completed", { b: 2 });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const first = await reader.next();
|
|
202
|
+
const second = await reader.next();
|
|
203
|
+
expect(first.value).toEqual({ name: "turn/started", payload: { a: 1 } });
|
|
204
|
+
expect(second.value).toEqual({ name: "turn/completed", payload: { b: 2 } });
|
|
205
|
+
|
|
206
|
+
await reader.return?.();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("stops yielding after return() unsubscribes", async () => {
|
|
210
|
+
const d = new ProtocolDispatcher();
|
|
211
|
+
const iter = subscribeNotifications(d, ["turn/started"]);
|
|
212
|
+
const reader = iter[Symbol.asyncIterator]();
|
|
213
|
+
const done = await reader.return?.();
|
|
214
|
+
expect(done?.done).toBe(true);
|
|
215
|
+
// Emitting after teardown must not throw or buffer indefinitely.
|
|
216
|
+
expect(() => d.emitNotification("turn/started", {})).not.toThrow();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-agnostic SSE / HTTP transport binding for `ProtocolDispatcher`.
|
|
3
|
+
*
|
|
4
|
+
* Built on Web-standard primitives ONLY — `Request`, `Response`,
|
|
5
|
+
* `ReadableStream`, `TextEncoder`, `AbortSignal`. There is no Hono / Express /
|
|
6
|
+
* `node:http` dependency, so the same code runs unchanged on edge runtimes,
|
|
7
|
+
* Cloudflare Workers, Deno, Bun, and Node (>=18, where these globals exist).
|
|
8
|
+
*
|
|
9
|
+
* Design goals:
|
|
10
|
+
* - Pure functions returning Web objects; nothing is mutated in place.
|
|
11
|
+
* - The dispatcher already fails closed on a missing `tenantId` (its
|
|
12
|
+
* `requestEnvelopeSchema` makes `tenantId` mandatory). This binding does
|
|
13
|
+
* not re-implement that policy — it faithfully relays the JSON-RPC error.
|
|
14
|
+
* - Never throw across the transport boundary and never leak a stack trace:
|
|
15
|
+
* a malformed body becomes a well-formed JSON-RPC parse error.
|
|
16
|
+
*
|
|
17
|
+
* Wire framing (SSE): each event is emitted as
|
|
18
|
+
* `event: <ThreadEvent.type>\n` + `data: <JSON(event)>\n\n`
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ProtocolDispatcher } from "../dispatcher";
|
|
22
|
+
import type { ThreadEvent } from "../model";
|
|
23
|
+
import type { NotificationName } from "../protocol";
|
|
24
|
+
|
|
25
|
+
// ── JSON-RPC constants ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Spec code for "Parse error" — the request body was not valid JSON. */
|
|
28
|
+
const ERR_PARSE = -32700;
|
|
29
|
+
|
|
30
|
+
const JSON_HEADERS: Readonly<Record<string, string>> = {
|
|
31
|
+
"content-type": "application/json; charset=utf-8",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A self-contained JSON-RPC failure envelope (used only for parse errors;
|
|
35
|
+
* every other failure path is produced by the dispatcher itself). */
|
|
36
|
+
function parseErrorEnvelope(message: string): {
|
|
37
|
+
readonly jsonrpc: "2.0";
|
|
38
|
+
readonly id: null;
|
|
39
|
+
readonly error: { readonly code: number; readonly message: string };
|
|
40
|
+
} {
|
|
41
|
+
return { jsonrpc: "2.0", id: null, error: { code: ERR_PARSE, message } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function jsonResponse(payload: unknown, status: number): Response {
|
|
45
|
+
return new Response(JSON.stringify(payload), { status, headers: { ...JSON_HEADERS } });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── HTTP / JSON-RPC handler ──────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a Web-standard request handler bound to a dispatcher.
|
|
52
|
+
*
|
|
53
|
+
* Contract:
|
|
54
|
+
* - Unparseable body → `400` with a JSON-RPC parse-error envelope.
|
|
55
|
+
* - Any parseable body (incl. missing tenant / unknown method / handler
|
|
56
|
+
* failure) → `200` with the dispatcher's own JSON-RPC envelope. The HTTP
|
|
57
|
+
* layer stays `200` for business-level JSON-RPC errors per JSON-RPC-over-
|
|
58
|
+
* HTTP convention; the error lives in the envelope, not the status line.
|
|
59
|
+
* - The returned function never throws and never emits a 500-with-stack.
|
|
60
|
+
*/
|
|
61
|
+
export function createRpcHandler(
|
|
62
|
+
dispatcher: ProtocolDispatcher,
|
|
63
|
+
): (request: Request) => Promise<Response> {
|
|
64
|
+
return async (request: Request): Promise<Response> => {
|
|
65
|
+
let raw: unknown;
|
|
66
|
+
try {
|
|
67
|
+
raw = await request.json();
|
|
68
|
+
} catch {
|
|
69
|
+
return jsonResponse(parseErrorEnvelope("Request body is not valid JSON"), 400);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `dispatch` is documented to never throw; the try/catch is defence in
|
|
73
|
+
// depth so the transport boundary is total under any future change.
|
|
74
|
+
try {
|
|
75
|
+
const envelope = await dispatcher.dispatch(raw);
|
|
76
|
+
return jsonResponse(envelope, 200);
|
|
77
|
+
} catch {
|
|
78
|
+
return jsonResponse(parseErrorEnvelope("Dispatcher failure"), 400);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── SSE streaming ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export interface SseOptions {
|
|
86
|
+
/** Cancels the stream cleanly; closing the consumer also cancels it. */
|
|
87
|
+
readonly signal?: AbortSignal;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Encode one `ThreadEvent` as a single SSE frame. */
|
|
91
|
+
function frame(event: ThreadEvent): string {
|
|
92
|
+
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Encode a terminal transport-level error as an `event: error` SSE frame. */
|
|
96
|
+
function errorFrame(message: string): string {
|
|
97
|
+
return `event: error\ndata: ${JSON.stringify({ type: "error", message })}\n\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Adapt an async `ThreadEvent` stream into a Web-standard
|
|
102
|
+
* `text/event-stream` `Response`.
|
|
103
|
+
*
|
|
104
|
+
* Guarantees:
|
|
105
|
+
* - Incremental flush: each event is enqueued as soon as it is produced.
|
|
106
|
+
* - Clean termination: the stream closes when the iterable is exhausted.
|
|
107
|
+
* - Error surfacing: an iterable error is emitted as a final
|
|
108
|
+
* `event: error` frame, then the stream closes (it never hangs).
|
|
109
|
+
* - Cancellation: an aborted `signal` (or a cancelled reader) stops
|
|
110
|
+
* pulling from the source and releases its iterator.
|
|
111
|
+
*/
|
|
112
|
+
export function sseResponse(stream: AsyncIterable<ThreadEvent>, opts?: SseOptions): Response {
|
|
113
|
+
const encoder = new TextEncoder();
|
|
114
|
+
const signal = opts?.signal;
|
|
115
|
+
|
|
116
|
+
const body = new ReadableStream<Uint8Array>({
|
|
117
|
+
async start(controller) {
|
|
118
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
119
|
+
|
|
120
|
+
const onAbort = () => {
|
|
121
|
+
void iterator.return?.(undefined);
|
|
122
|
+
};
|
|
123
|
+
if (signal) {
|
|
124
|
+
if (signal.aborted) {
|
|
125
|
+
await iterator.return?.(undefined);
|
|
126
|
+
controller.close();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
for (;;) {
|
|
134
|
+
if (signal?.aborted) break;
|
|
135
|
+
const next = await iterator.next();
|
|
136
|
+
if (next.done) break;
|
|
137
|
+
controller.enqueue(encoder.encode(frame(next.value)));
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : "Stream error";
|
|
141
|
+
controller.enqueue(encoder.encode(errorFrame(message)));
|
|
142
|
+
} finally {
|
|
143
|
+
signal?.removeEventListener("abort", onAbort);
|
|
144
|
+
controller.close();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
cancel() {
|
|
148
|
+
// Consumer hung up: best-effort release of the upstream iterator.
|
|
149
|
+
void stream[Symbol.asyncIterator]().return?.(undefined);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return new Response(body, {
|
|
154
|
+
status: 200,
|
|
155
|
+
headers: {
|
|
156
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
157
|
+
"cache-control": "no-cache, no-transform",
|
|
158
|
+
connection: "keep-alive",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Notification → async-iterable bridge ─────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export interface NotificationFrame {
|
|
166
|
+
readonly name: NotificationName;
|
|
167
|
+
readonly payload: unknown;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Bridge `dispatcher.onNotification` (a push listener API) into a pull-based
|
|
172
|
+
* `AsyncIterable`. A bounded backlog buffers events that arrive between
|
|
173
|
+
* `next()` calls; pending readers are resolved immediately when an event
|
|
174
|
+
* arrives. Calling `return()` (or breaking a `for await`) unsubscribes every
|
|
175
|
+
* underlying listener — no leaks, fail-closed teardown.
|
|
176
|
+
*/
|
|
177
|
+
export function subscribeNotifications(
|
|
178
|
+
dispatcher: ProtocolDispatcher,
|
|
179
|
+
names: readonly NotificationName[],
|
|
180
|
+
): AsyncIterable<NotificationFrame> {
|
|
181
|
+
return {
|
|
182
|
+
[Symbol.asyncIterator](): AsyncIterator<NotificationFrame> {
|
|
183
|
+
const backlog: NotificationFrame[] = [];
|
|
184
|
+
const waiters: Array<(r: IteratorResult<NotificationFrame>) => void> = [];
|
|
185
|
+
let closed = false;
|
|
186
|
+
|
|
187
|
+
const unsubscribers = names.map((name) =>
|
|
188
|
+
dispatcher.onNotification(name, (payload) => {
|
|
189
|
+
if (closed) return;
|
|
190
|
+
const value: NotificationFrame = { name, payload };
|
|
191
|
+
const waiter = waiters.shift();
|
|
192
|
+
if (waiter) waiter({ value, done: false });
|
|
193
|
+
else backlog.push(value);
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const teardown = (): void => {
|
|
198
|
+
if (closed) return;
|
|
199
|
+
closed = true;
|
|
200
|
+
for (const off of unsubscribers) off();
|
|
201
|
+
let waiter = waiters.shift();
|
|
202
|
+
while (waiter) {
|
|
203
|
+
waiter({ value: undefined, done: true });
|
|
204
|
+
waiter = waiters.shift();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
next(): Promise<IteratorResult<NotificationFrame>> {
|
|
210
|
+
const buffered = backlog.shift();
|
|
211
|
+
if (buffered) return Promise.resolve({ value: buffered, done: false });
|
|
212
|
+
if (closed) return Promise.resolve({ value: undefined, done: true });
|
|
213
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
214
|
+
},
|
|
215
|
+
return(): Promise<IteratorResult<NotificationFrame>> {
|
|
216
|
+
teardown();
|
|
217
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nebutra/agent-runtime/adapters — concrete, reusable port adapters.
|
|
3
|
+
*
|
|
4
|
+
* The root runtime keeps its pure grammar and injectable ports. These subpath
|
|
5
|
+
* exports supply concrete wirings so apps/backends reuse them instead of
|
|
6
|
+
* re-implementing glue:
|
|
7
|
+
*
|
|
8
|
+
* - `./mcp-catalog` — McpServerCatalogPort + McpClientLike over @nebutra/mcp
|
|
9
|
+
* - `./dispatcher-sse` — runtime-agnostic SSE transport for ProtocolDispatcher
|
|
10
|
+
*
|
|
11
|
+
* The durable rollout-store backend adapter is pending a governance decision
|
|
12
|
+
* (a correct system-of-record needs a fail-loud datastore; @nebutra/audit's
|
|
13
|
+
* log() swallows transient failures and is not suitable as source-of-truth).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export * from "./dispatcher-sse.js";
|
|
17
|
+
export * from "./mcp-catalog.js";
|
|
18
|
+
export * from "./prisma-rollout.js";
|