@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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
admitInbound,
|
|
5
|
+
compileAllowlist,
|
|
6
|
+
hasMention,
|
|
7
|
+
InboundDebouncer,
|
|
8
|
+
type InboundLike,
|
|
9
|
+
matchesAllowlist,
|
|
10
|
+
requiresMention,
|
|
11
|
+
resolveSessionKey,
|
|
12
|
+
} from "./inbound-admission.js";
|
|
13
|
+
|
|
14
|
+
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function makeInbound(overrides: Partial<InboundLike> = {}): InboundLike {
|
|
17
|
+
return {
|
|
18
|
+
tenantId: "tenant-a",
|
|
19
|
+
channelId: "chan-1",
|
|
20
|
+
chatId: "chat-9",
|
|
21
|
+
senderId: "user-7",
|
|
22
|
+
text: "hello there",
|
|
23
|
+
receivedAt: "2026-05-17T10:00:00.000Z",
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── 1. resolveSessionKey ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("resolveSessionKey", () => {
|
|
31
|
+
it("is deterministic for the same inputs", () => {
|
|
32
|
+
const inbound = makeInbound();
|
|
33
|
+
expect(resolveSessionKey(inbound)).toBe(resolveSessionKey(inbound));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("is stable across object identity (re-created equal inputs)", () => {
|
|
37
|
+
const a = resolveSessionKey(makeInbound());
|
|
38
|
+
const b = resolveSessionKey(makeInbound());
|
|
39
|
+
expect(a).toBe(b);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("never collides across tenants for otherwise-identical inputs", () => {
|
|
43
|
+
const t1 = resolveSessionKey(makeInbound({ tenantId: "tenant-a" }));
|
|
44
|
+
const t2 = resolveSessionKey(makeInbound({ tenantId: "tenant-b" }));
|
|
45
|
+
expect(t1).not.toBe(t2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("prefixes the key with the tenant id (cross-tenant isolation by construction)", () => {
|
|
49
|
+
const key = resolveSessionKey(makeInbound({ tenantId: "tenant-zed" }));
|
|
50
|
+
expect(key.startsWith("tenant-zed:")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("group default binding is per-chat: same chat different sender → same key", () => {
|
|
54
|
+
const a = resolveSessionKey(makeInbound({ senderId: "s1" }), { chatType: "group" });
|
|
55
|
+
const b = resolveSessionKey(makeInbound({ senderId: "s2" }), { chatType: "group" });
|
|
56
|
+
expect(a).toBe(b);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("group binding per-sender: different sender → different key", () => {
|
|
60
|
+
const a = resolveSessionKey(makeInbound({ senderId: "s1" }), {
|
|
61
|
+
chatType: "group",
|
|
62
|
+
groupBinding: "per-sender",
|
|
63
|
+
});
|
|
64
|
+
const b = resolveSessionKey(makeInbound({ senderId: "s2" }), {
|
|
65
|
+
chatType: "group",
|
|
66
|
+
groupBinding: "per-sender",
|
|
67
|
+
});
|
|
68
|
+
expect(a).not.toBe(b);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("group binding per-thread: different thread → different key, same thread → same", () => {
|
|
72
|
+
const a = resolveSessionKey(makeInbound({ threadId: "th-1" }), {
|
|
73
|
+
chatType: "group",
|
|
74
|
+
groupBinding: "per-thread",
|
|
75
|
+
});
|
|
76
|
+
const b = resolveSessionKey(makeInbound({ threadId: "th-2" }), {
|
|
77
|
+
chatType: "group",
|
|
78
|
+
groupBinding: "per-thread",
|
|
79
|
+
});
|
|
80
|
+
const c = resolveSessionKey(makeInbound({ threadId: "th-1" }), {
|
|
81
|
+
chatType: "group",
|
|
82
|
+
groupBinding: "per-thread",
|
|
83
|
+
});
|
|
84
|
+
expect(a).not.toBe(b);
|
|
85
|
+
expect(a).toBe(c);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("DM default binding is per-sender: different sender → different key", () => {
|
|
89
|
+
const a = resolveSessionKey(makeInbound({ senderId: "s1" }), { chatType: "dm" });
|
|
90
|
+
const b = resolveSessionKey(makeInbound({ senderId: "s2" }), { chatType: "dm" });
|
|
91
|
+
expect(a).not.toBe(b);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("DM per-chat override collapses senders to one key", () => {
|
|
95
|
+
const a = resolveSessionKey(makeInbound({ senderId: "s1" }), {
|
|
96
|
+
chatType: "dm",
|
|
97
|
+
groupBinding: "per-chat",
|
|
98
|
+
});
|
|
99
|
+
const b = resolveSessionKey(makeInbound({ senderId: "s2" }), {
|
|
100
|
+
chatType: "dm",
|
|
101
|
+
groupBinding: "per-chat",
|
|
102
|
+
});
|
|
103
|
+
expect(a).toBe(b);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not mutate the inbound input", () => {
|
|
107
|
+
const inbound = makeInbound();
|
|
108
|
+
const snapshot = JSON.stringify(inbound);
|
|
109
|
+
resolveSessionKey(inbound, { chatType: "group" });
|
|
110
|
+
expect(JSON.stringify(inbound)).toBe(snapshot);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("fails closed on empty tenant id", () => {
|
|
114
|
+
expect(() => resolveSessionKey(makeInbound({ tenantId: "" }))).toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── 2. compileAllowlist / matchesAllowlist ───────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("compileAllowlist / matchesAllowlist", () => {
|
|
121
|
+
it("exact id match is allowed and reports matchedBy", () => {
|
|
122
|
+
const compiled = compileAllowlist(["user-7", "user-8"]);
|
|
123
|
+
const res = matchesAllowlist(compiled, ["user-7"]);
|
|
124
|
+
expect(res.allowed).toBe(true);
|
|
125
|
+
expect(res.matchedBy).toBe("user-7");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("denies when no candidate matches", () => {
|
|
129
|
+
const compiled = compileAllowlist(["user-7"]);
|
|
130
|
+
const res = matchesAllowlist(compiled, ["user-99"]);
|
|
131
|
+
expect(res.allowed).toBe(false);
|
|
132
|
+
expect(res.matchedBy).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("whole-entry wildcard '*' allows any candidate", () => {
|
|
136
|
+
const compiled = compileAllowlist(["*"]);
|
|
137
|
+
expect(matchesAllowlist(compiled, ["anyone"]).allowed).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("prefix wildcard 'prefix:*' matches by prefix", () => {
|
|
141
|
+
const compiled = compileAllowlist(["org-42:*"]);
|
|
142
|
+
expect(matchesAllowlist(compiled, ["org-42:user-1"]).allowed).toBe(true);
|
|
143
|
+
expect(matchesAllowlist(compiled, ["org-99:user-1"]).allowed).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("prefix wildcard reports the matching entry", () => {
|
|
147
|
+
const compiled = compileAllowlist(["org-42:*"]);
|
|
148
|
+
expect(matchesAllowlist(compiled, ["org-42:user-1"]).matchedBy).toBe("org-42:*");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("empty allowlist is closed by default (deny)", () => {
|
|
152
|
+
const compiled = compileAllowlist([]);
|
|
153
|
+
expect(matchesAllowlist(compiled, ["anyone"]).allowed).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("empty allowlist with openWhenEmpty allows everything", () => {
|
|
157
|
+
const compiled = compileAllowlist([], { openWhenEmpty: true });
|
|
158
|
+
expect(matchesAllowlist(compiled, ["anyone"]).allowed).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("openWhenEmpty does NOT open a non-empty allowlist", () => {
|
|
162
|
+
const compiled = compileAllowlist(["user-7"], { openWhenEmpty: true });
|
|
163
|
+
expect(matchesAllowlist(compiled, ["intruder"]).allowed).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("checks multiple candidates and matches any", () => {
|
|
167
|
+
const compiled = compileAllowlist(["role:admin"]);
|
|
168
|
+
expect(matchesAllowlist(compiled, ["user-7", "role:admin"]).allowed).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not mutate the entries array", () => {
|
|
172
|
+
const entries = ["a", "b:*"];
|
|
173
|
+
const snapshot = [...entries];
|
|
174
|
+
compileAllowlist(entries);
|
|
175
|
+
expect(entries).toEqual(snapshot);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── 3. requiresMention / hasMention ──────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
describe("requiresMention / hasMention", () => {
|
|
182
|
+
it("DM never requires a mention", () => {
|
|
183
|
+
expect(requiresMention({ chatType: "dm", assistantHandles: ["@bot"], text: "hi" })).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("group requires a mention when none present", () => {
|
|
187
|
+
expect(
|
|
188
|
+
requiresMention({ chatType: "group", assistantHandles: ["@bot"], text: "hello world" }),
|
|
189
|
+
).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("group does not require gating when message mentions the assistant", () => {
|
|
193
|
+
expect(
|
|
194
|
+
requiresMention({ chatType: "group", assistantHandles: ["@bot"], text: "hey @bot help" }),
|
|
195
|
+
).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("reply-to-assistant bypasses the mention requirement in group", () => {
|
|
199
|
+
expect(
|
|
200
|
+
requiresMention({
|
|
201
|
+
chatType: "group",
|
|
202
|
+
assistantHandles: ["@bot"],
|
|
203
|
+
text: "no mention here",
|
|
204
|
+
isReplyToAssistant: true,
|
|
205
|
+
}),
|
|
206
|
+
).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("hasMention is case-insensitive", () => {
|
|
210
|
+
expect(hasMention("Hey @BOT how are you", ["@bot"])).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("hasMention matches on word boundaries (no substring false positive)", () => {
|
|
214
|
+
expect(hasMention("contact @botanist now", ["@bot"])).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("hasMention is false when handle absent", () => {
|
|
218
|
+
expect(hasMention("nothing relevant", ["@bot"])).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("hasMention supports multiple handles", () => {
|
|
222
|
+
expect(hasMention("ping @assistant please", ["@bot", "@assistant"])).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ── 4. InboundDebouncer ──────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
describe("InboundDebouncer", () => {
|
|
229
|
+
it("does not admit a message while the window is open", () => {
|
|
230
|
+
const d = new InboundDebouncer();
|
|
231
|
+
expect(d.offer("sk", "first", 0)).toEqual({ admit: false });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("coalesces a burst within the window and admits once on flush", () => {
|
|
235
|
+
const d = new InboundDebouncer({ windowMs: 1500 });
|
|
236
|
+
expect(d.offer("sk", "one", 0).admit).toBe(false);
|
|
237
|
+
expect(d.offer("sk", "two", 500).admit).toBe(false);
|
|
238
|
+
const flushed = d.flush("sk", 600);
|
|
239
|
+
expect(flushed).toEqual({ admit: true, merged: "one\ntwo" });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("admits a coalesced burst once the window has closed via a later offer", () => {
|
|
243
|
+
const d = new InboundDebouncer({ windowMs: 1000 });
|
|
244
|
+
expect(d.offer("sk", "a", 0).admit).toBe(false);
|
|
245
|
+
expect(d.offer("sk", "b", 200).admit).toBe(false);
|
|
246
|
+
const res = d.offer("sk", "c", 2000);
|
|
247
|
+
expect(res).toEqual({ admit: true, merged: "a\nb" });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("flush on an unknown sessionKey does not admit", () => {
|
|
251
|
+
const d = new InboundDebouncer();
|
|
252
|
+
expect(d.flush("never-seen", 1000)).toEqual({ admit: false });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("treats distinct sessionKeys independently", () => {
|
|
256
|
+
const d = new InboundDebouncer({ windowMs: 1000 });
|
|
257
|
+
d.offer("sk-1", "x", 0);
|
|
258
|
+
d.offer("sk-2", "y", 0);
|
|
259
|
+
expect(d.flush("sk-1", 1100)).toEqual({ admit: true, merged: "x" });
|
|
260
|
+
expect(d.flush("sk-2", 1100)).toEqual({ admit: true, merged: "y" });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("flush clears buffered state (second flush does not re-admit)", () => {
|
|
264
|
+
const d = new InboundDebouncer({ windowMs: 1000 });
|
|
265
|
+
d.offer("sk", "only", 0);
|
|
266
|
+
expect(d.flush("sk", 1100).admit).toBe(true);
|
|
267
|
+
expect(d.flush("sk", 1200)).toEqual({ admit: false });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("starts a fresh window after a prior burst was flushed by a late offer", () => {
|
|
271
|
+
const d = new InboundDebouncer({ windowMs: 1000 });
|
|
272
|
+
d.offer("sk", "a", 0);
|
|
273
|
+
const first = d.offer("sk", "b", 5000);
|
|
274
|
+
expect(first).toEqual({ admit: true, merged: "a" });
|
|
275
|
+
// 'b' begins a new window — still pending until it closes.
|
|
276
|
+
expect(d.offer("sk", "c", 5200).admit).toBe(false);
|
|
277
|
+
expect(d.flush("sk", 6300)).toEqual({ admit: true, merged: "b\nc" });
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── 5. admitInbound (compose) ────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
describe("admitInbound", () => {
|
|
284
|
+
function basePolicy() {
|
|
285
|
+
return {
|
|
286
|
+
chatType: "group" as const,
|
|
287
|
+
allowlist: compileAllowlist(["user-7"]),
|
|
288
|
+
assistantHandles: ["@bot"],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
it("fails closed when tenant id is empty", () => {
|
|
293
|
+
const d = new InboundDebouncer();
|
|
294
|
+
const res = admitInbound(makeInbound({ tenantId: "" }), basePolicy(), { debouncer: d });
|
|
295
|
+
expect(res).toEqual({ admit: false, reason: "empty-tenant" });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rejects a sender that is not allowlisted", () => {
|
|
299
|
+
const d = new InboundDebouncer();
|
|
300
|
+
const res = admitInbound(makeInbound({ senderId: "intruder", text: "@bot hi" }), basePolicy(), {
|
|
301
|
+
debouncer: d,
|
|
302
|
+
});
|
|
303
|
+
expect(res).toEqual({ admit: false, reason: "not-allowlisted" });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("rejects a group message without a mention", () => {
|
|
307
|
+
const d = new InboundDebouncer();
|
|
308
|
+
const res = admitInbound(
|
|
309
|
+
makeInbound({ senderId: "user-7", text: "no mention" }),
|
|
310
|
+
basePolicy(),
|
|
311
|
+
{ debouncer: d },
|
|
312
|
+
);
|
|
313
|
+
expect(res).toEqual({ admit: false, reason: "mention-required" });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("debounces an admitted message until the window closes", () => {
|
|
317
|
+
const d = new InboundDebouncer({ windowMs: 1000 });
|
|
318
|
+
const res = admitInbound(
|
|
319
|
+
makeInbound({ senderId: "user-7", text: "@bot hello" }),
|
|
320
|
+
basePolicy(),
|
|
321
|
+
{ debouncer: d, nowMs: 0 },
|
|
322
|
+
);
|
|
323
|
+
expect(res).toEqual({ admit: false, reason: "debounced" });
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("admits end-to-end once the debounce window closes", () => {
|
|
327
|
+
const d = new InboundDebouncer({ windowMs: 1000 });
|
|
328
|
+
const policy = basePolicy();
|
|
329
|
+
const i1 = makeInbound({ senderId: "user-7", text: "@bot one" });
|
|
330
|
+
expect(admitInbound(i1, policy, { debouncer: d, nowMs: 0 }).admit).toBe(false);
|
|
331
|
+
const i2 = makeInbound({ senderId: "user-7", text: "two" });
|
|
332
|
+
const res = admitInbound(i2, policy, { debouncer: d, nowMs: 5000 });
|
|
333
|
+
expect(res.admit).toBe(true);
|
|
334
|
+
if (res.admit) {
|
|
335
|
+
expect(res.text).toBe("@bot one");
|
|
336
|
+
expect(res.sessionKey.startsWith("tenant-a:")).toBe(true);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("admits a DM without requiring a mention (still debounced first)", () => {
|
|
341
|
+
const d = new InboundDebouncer({ windowMs: 500 });
|
|
342
|
+
const policy = {
|
|
343
|
+
chatType: "dm" as const,
|
|
344
|
+
allowlist: compileAllowlist(["user-7"]),
|
|
345
|
+
assistantHandles: ["@bot"],
|
|
346
|
+
};
|
|
347
|
+
const i1 = makeInbound({ senderId: "user-7", text: "plain dm" });
|
|
348
|
+
const r0 = admitInbound(i1, policy, { debouncer: d, nowMs: 0 });
|
|
349
|
+
expect(r0.admit).toBe(false);
|
|
350
|
+
if (!r0.admit) expect(r0.reason).toBe("debounced");
|
|
351
|
+
const i2 = makeInbound({ senderId: "user-7", text: "later" });
|
|
352
|
+
const res = admitInbound(i2, policy, { debouncer: d, nowMs: 9000 });
|
|
353
|
+
expect(res.admit).toBe(true);
|
|
354
|
+
if (res.admit) expect(res.text).toBe("plain dm");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("allowlist check precedes mention check (allowlist reason wins)", () => {
|
|
358
|
+
const d = new InboundDebouncer();
|
|
359
|
+
const res = admitInbound(
|
|
360
|
+
makeInbound({ senderId: "ghost", text: "no mention either" }),
|
|
361
|
+
basePolicy(),
|
|
362
|
+
{ debouncer: d },
|
|
363
|
+
);
|
|
364
|
+
expect(res).toEqual({ admit: false, reason: "not-allowlisted" });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("does not mutate the inbound input", () => {
|
|
368
|
+
const d = new InboundDebouncer();
|
|
369
|
+
const inbound = makeInbound({ senderId: "user-7", text: "@bot hi" });
|
|
370
|
+
const snapshot = JSON.stringify(inbound);
|
|
371
|
+
admitInbound(inbound, basePolicy(), { debouncer: d, nowMs: 0 });
|
|
372
|
+
expect(JSON.stringify(inbound)).toBe(snapshot);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("reply-to-assistant bypasses mention gating in a group", () => {
|
|
376
|
+
const d = new InboundDebouncer({ windowMs: 100 });
|
|
377
|
+
const policy = basePolicy();
|
|
378
|
+
const i1 = makeInbound({ senderId: "user-7", text: "no mention but a reply" });
|
|
379
|
+
const r1 = admitInbound(i1, policy, {
|
|
380
|
+
debouncer: d,
|
|
381
|
+
nowMs: 0,
|
|
382
|
+
isReplyToAssistant: true,
|
|
383
|
+
});
|
|
384
|
+
expect(r1.admit).toBe(false);
|
|
385
|
+
if (!r1.admit) expect(r1.reason).toBe("debounced");
|
|
386
|
+
const i2 = makeInbound({ senderId: "user-7", text: "again" });
|
|
387
|
+
const r2 = admitInbound(i2, policy, {
|
|
388
|
+
debouncer: d,
|
|
389
|
+
nowMs: 9000,
|
|
390
|
+
isReplyToAssistant: true,
|
|
391
|
+
});
|
|
392
|
+
expect(r2.admit).toBe(true);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound admission + conversation resolution (the multi-channel "front door").
|
|
3
|
+
*
|
|
4
|
+
* Faithful re-expression of a multi-channel assistant's pre-turn policy:
|
|
5
|
+
* resolve a stable tenant-scoped session key from a normalized inbound
|
|
6
|
+
* message, then gate it through allowlist → mention → debounce before it is
|
|
7
|
+
* allowed to become an agent turn. Pure data/logic; time is injected (no
|
|
8
|
+
* timers); tenant-scoped & fail-closed. No transport, no host I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
/** Minimal structural inbound shape (declared locally — no sibling imports). */
|
|
14
|
+
export interface InboundLike {
|
|
15
|
+
readonly tenantId: string;
|
|
16
|
+
readonly channelId: string;
|
|
17
|
+
readonly chatId: string;
|
|
18
|
+
readonly threadId?: string | undefined;
|
|
19
|
+
readonly senderId: string;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly receivedAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ChatType = "dm" | "group";
|
|
25
|
+
export type GroupBinding = "per-chat" | "per-sender" | "per-thread";
|
|
26
|
+
|
|
27
|
+
export interface SessionKeyOptions {
|
|
28
|
+
readonly chatType?: ChatType | undefined;
|
|
29
|
+
readonly groupBinding?: GroupBinding | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const tenantSchema = z.string().min(1, "tenantId is required");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deterministic, tenant-prefixed conversation/session key. Cross-tenant keys
|
|
36
|
+
* can never collide (tenant is the first segment). Defaults: a group binds
|
|
37
|
+
* per-chat, a DM binds per-sender; an explicit `groupBinding` overrides.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveSessionKey(inbound: InboundLike, opts: SessionKeyOptions = {}): string {
|
|
40
|
+
const tenantId = tenantSchema.parse(inbound.tenantId);
|
|
41
|
+
const chatType: ChatType = opts.chatType ?? "group";
|
|
42
|
+
const binding: GroupBinding =
|
|
43
|
+
opts.groupBinding ?? (chatType === "dm" ? "per-sender" : "per-chat");
|
|
44
|
+
|
|
45
|
+
const parts: string[] = [tenantId, inbound.channelId, inbound.chatId];
|
|
46
|
+
if (binding === "per-sender") parts.push(inbound.senderId);
|
|
47
|
+
else if (binding === "per-thread") parts.push(inbound.threadId ?? "");
|
|
48
|
+
return parts.join(":");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Allowlist ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface CompiledAllowlist {
|
|
54
|
+
readonly exact: ReadonlySet<string>;
|
|
55
|
+
readonly prefixes: ReadonlyArray<{ readonly prefix: string; readonly entry: string }>;
|
|
56
|
+
readonly matchAny: boolean;
|
|
57
|
+
readonly empty: boolean;
|
|
58
|
+
readonly openWhenEmpty: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AllowlistMatch {
|
|
62
|
+
readonly allowed: boolean;
|
|
63
|
+
readonly matchedBy?: string | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function compileAllowlist(
|
|
67
|
+
entries: ReadonlyArray<string>,
|
|
68
|
+
opts: { openWhenEmpty?: boolean } = {},
|
|
69
|
+
): CompiledAllowlist {
|
|
70
|
+
const exact = new Set<string>();
|
|
71
|
+
const prefixes: { prefix: string; entry: string }[] = [];
|
|
72
|
+
let matchAny = false;
|
|
73
|
+
for (const raw of entries) {
|
|
74
|
+
const entry = raw.trim();
|
|
75
|
+
if (entry === "") continue;
|
|
76
|
+
if (entry === "*") {
|
|
77
|
+
matchAny = true;
|
|
78
|
+
} else if (entry.endsWith(":*")) {
|
|
79
|
+
prefixes.push({ prefix: entry.slice(0, -1), entry }); // keep through the ':'
|
|
80
|
+
} else {
|
|
81
|
+
exact.add(entry);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const empty = !matchAny && exact.size === 0 && prefixes.length === 0;
|
|
85
|
+
return {
|
|
86
|
+
exact,
|
|
87
|
+
prefixes,
|
|
88
|
+
matchAny,
|
|
89
|
+
empty,
|
|
90
|
+
openWhenEmpty: opts.openWhenEmpty === true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function matchesAllowlist(
|
|
95
|
+
compiled: CompiledAllowlist,
|
|
96
|
+
candidates: ReadonlyArray<string>,
|
|
97
|
+
): AllowlistMatch {
|
|
98
|
+
if (compiled.empty) {
|
|
99
|
+
return compiled.openWhenEmpty ? { allowed: true } : { allowed: false };
|
|
100
|
+
}
|
|
101
|
+
if (compiled.matchAny) return { allowed: true, matchedBy: "*" };
|
|
102
|
+
for (const c of candidates) {
|
|
103
|
+
if (compiled.exact.has(c)) return { allowed: true, matchedBy: c };
|
|
104
|
+
const pre = compiled.prefixes.find((p) => c.startsWith(p.prefix));
|
|
105
|
+
if (pre) return { allowed: true, matchedBy: pre.entry };
|
|
106
|
+
}
|
|
107
|
+
return { allowed: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Mention gating ───────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function escapeRegExp(s: string): string {
|
|
113
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Case-insensitive, word-boundary-aware handle detection. */
|
|
117
|
+
export function hasMention(text: string, handles: ReadonlyArray<string>): boolean {
|
|
118
|
+
return handles.some((h) => {
|
|
119
|
+
const handle = h.trim();
|
|
120
|
+
if (handle === "") return false;
|
|
121
|
+
// Not followed by a word char → `@bot` ≠ `@botanist`.
|
|
122
|
+
const re = new RegExp(`${escapeRegExp(handle)}(?!\\w)`, "i");
|
|
123
|
+
return re.test(text);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface MentionContext {
|
|
128
|
+
readonly chatType: ChatType;
|
|
129
|
+
readonly assistantHandles: ReadonlyArray<string>;
|
|
130
|
+
readonly text: string;
|
|
131
|
+
readonly isReplyToAssistant?: boolean | undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A group message is gated unless it mentions the assistant or replies to it. */
|
|
135
|
+
export function requiresMention(ctx: MentionContext): boolean {
|
|
136
|
+
if (ctx.chatType === "dm") return false;
|
|
137
|
+
if (ctx.isReplyToAssistant === true) return false;
|
|
138
|
+
return !hasMention(ctx.text, ctx.assistantHandles);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Debounce ─────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export type DebounceResult = { admit: false } | { admit: true; merged: string };
|
|
144
|
+
|
|
145
|
+
interface DebounceBuffer {
|
|
146
|
+
windowStart: number;
|
|
147
|
+
parts: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Coalesces a burst per session key. Time is injected (`nowMs`). A later
|
|
152
|
+
* offer whose window has closed flushes (admits) the prior buffer and opens
|
|
153
|
+
* a fresh window for the new message. `flush` force-admits regardless of the
|
|
154
|
+
* window and clears state.
|
|
155
|
+
*/
|
|
156
|
+
export class InboundDebouncer {
|
|
157
|
+
readonly #windowMs: number;
|
|
158
|
+
readonly #buffers = new Map<string, DebounceBuffer>();
|
|
159
|
+
|
|
160
|
+
constructor(opts: { windowMs?: number } = {}) {
|
|
161
|
+
this.#windowMs = opts.windowMs ?? 1500;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
offer(sessionKey: string, message: string, nowMs: number): DebounceResult {
|
|
165
|
+
const buf = this.#buffers.get(sessionKey);
|
|
166
|
+
if (!buf) {
|
|
167
|
+
this.#buffers.set(sessionKey, { windowStart: nowMs, parts: [message] });
|
|
168
|
+
return { admit: false };
|
|
169
|
+
}
|
|
170
|
+
if (nowMs - buf.windowStart >= this.#windowMs) {
|
|
171
|
+
const merged = buf.parts.join("\n");
|
|
172
|
+
this.#buffers.set(sessionKey, { windowStart: nowMs, parts: [message] });
|
|
173
|
+
return { admit: true, merged };
|
|
174
|
+
}
|
|
175
|
+
buf.parts.push(message);
|
|
176
|
+
return { admit: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
flush(sessionKey: string, _nowMs: number): DebounceResult {
|
|
180
|
+
const buf = this.#buffers.get(sessionKey);
|
|
181
|
+
if (!buf) return { admit: false };
|
|
182
|
+
this.#buffers.delete(sessionKey);
|
|
183
|
+
return { admit: true, merged: buf.parts.join("\n") };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Compose ──────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export interface AdmissionPolicy {
|
|
190
|
+
readonly chatType: ChatType;
|
|
191
|
+
readonly allowlist: CompiledAllowlist;
|
|
192
|
+
readonly assistantHandles: ReadonlyArray<string>;
|
|
193
|
+
readonly groupBinding?: GroupBinding | undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface AdmissionDeps {
|
|
197
|
+
readonly debouncer: InboundDebouncer;
|
|
198
|
+
readonly nowMs?: number | undefined;
|
|
199
|
+
readonly isReplyToAssistant?: boolean | undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type AdmissionReason = "empty-tenant" | "not-allowlisted" | "mention-required" | "debounced";
|
|
203
|
+
|
|
204
|
+
export type AdmissionResult =
|
|
205
|
+
| { readonly admit: true; readonly sessionKey: string; readonly text: string }
|
|
206
|
+
| { readonly admit: false; readonly reason: AdmissionReason };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Pipeline: tenant → allowlist → session key → debounce → mention.
|
|
210
|
+
*
|
|
211
|
+
* A window-closing offer admits the PRIOR (already-gated) buffered content,
|
|
212
|
+
* so a message that itself lacks a mention can still flush the conversation.
|
|
213
|
+
* The current message's mention gate only governs whether it produces a
|
|
214
|
+
* `mention-required` vs `debounced` outcome while it waits.
|
|
215
|
+
*/
|
|
216
|
+
export function admitInbound(
|
|
217
|
+
inbound: InboundLike,
|
|
218
|
+
policy: AdmissionPolicy,
|
|
219
|
+
deps: AdmissionDeps,
|
|
220
|
+
): AdmissionResult {
|
|
221
|
+
if (typeof inbound.tenantId !== "string" || inbound.tenantId.trim() === "") {
|
|
222
|
+
return { admit: false, reason: "empty-tenant" };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const allow = matchesAllowlist(policy.allowlist, [inbound.senderId]);
|
|
226
|
+
if (!allow.allowed) return { admit: false, reason: "not-allowlisted" };
|
|
227
|
+
|
|
228
|
+
const sessionKey = resolveSessionKey(inbound, {
|
|
229
|
+
chatType: policy.chatType,
|
|
230
|
+
groupBinding: policy.groupBinding,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const nowMs = deps.nowMs ?? Date.now();
|
|
234
|
+
const result = deps.debouncer.offer(sessionKey, inbound.text, nowMs);
|
|
235
|
+
if (result.admit) {
|
|
236
|
+
return { admit: true, sessionKey, text: result.merged };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const gated = requiresMention({
|
|
240
|
+
chatType: policy.chatType,
|
|
241
|
+
assistantHandles: policy.assistantHandles,
|
|
242
|
+
text: inbound.text,
|
|
243
|
+
isReplyToAssistant: deps.isReplyToAssistant,
|
|
244
|
+
});
|
|
245
|
+
return { admit: false, reason: gated ? "mention-required" : "debounced" };
|
|
246
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nebutra/agent-runtime — multi-tenant agent-runtime grammar.
|
|
3
|
+
*
|
|
4
|
+
* A faithful re-expression of a terminal coding-agent's runtime *design*
|
|
5
|
+
* (thread/turn/item model, approval + capability policy, uniform tool/MCP
|
|
6
|
+
* abstraction, event-sourced rollout, external-sandbox delegation) into
|
|
7
|
+
* Sailor's grammar: TypeScript, multi-tenant, no infra changes, no in-process
|
|
8
|
+
* untrusted-code execution.
|
|
9
|
+
*
|
|
10
|
+
* Track A (this package): policy + protocol + model + rollout, all tenant-scoped.
|
|
11
|
+
* Track B (decoupled, not this repo): an optional isolator/kernel sidecar that
|
|
12
|
+
* implements {@link ExternalSandbox} over the ./protocol contract.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export * from "./artifact-stream";
|
|
16
|
+
export * from "./channel-gateway";
|
|
17
|
+
export * from "./code-review";
|
|
18
|
+
export * from "./command-suggestions";
|
|
19
|
+
export * from "./commands";
|
|
20
|
+
export * from "./commit-message";
|
|
21
|
+
export * from "./context-compaction";
|
|
22
|
+
export * from "./definitions";
|
|
23
|
+
export * from "./deployment-status";
|
|
24
|
+
export * from "./design-context";
|
|
25
|
+
export * from "./dispatcher";
|
|
26
|
+
export * from "./durable-turn";
|
|
27
|
+
export * from "./edit-planner";
|
|
28
|
+
export * from "./fuzzy-match";
|
|
29
|
+
export * from "./hook-pipeline";
|
|
30
|
+
export * from "./inbound-admission";
|
|
31
|
+
export * from "./loop";
|
|
32
|
+
export * from "./mcp-bridge";
|
|
33
|
+
export * from "./memory-provider";
|
|
34
|
+
export * from "./model";
|
|
35
|
+
export * from "./permission-ruleset";
|
|
36
|
+
export * from "./policy";
|
|
37
|
+
export * from "./project-repo";
|
|
38
|
+
export * from "./protocol";
|
|
39
|
+
export * from "./rollout";
|
|
40
|
+
export * from "./rollout-store-persistent";
|
|
41
|
+
export * from "./sandbox";
|
|
42
|
+
export * from "./session-share";
|
|
43
|
+
export * from "./skill-distillation";
|
|
44
|
+
export * from "./skills";
|
|
45
|
+
export * from "./subagents";
|
|
46
|
+
export * from "./tools";
|
|
47
|
+
export * from "./workbench";
|