@nebutra/agent-runtime 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +2 -0
  3. package/dist/adapters/dispatcher-sse.js +1 -0
  4. package/dist/adapters/index.d.ts +24 -5
  5. package/dist/adapters/index.js +28 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/adapters/mcp-catalog.js +1 -0
  8. package/dist/adapters/prisma-rollout.js +1 -0
  9. package/dist/chunk-424PT5DM.js +23 -0
  10. package/dist/chunk-424PT5DM.js.map +1 -0
  11. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  12. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  13. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  14. package/dist/chunk-D4YAPLOW.js.map +1 -0
  15. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  16. package/dist/chunk-GQZKYWFT.js.map +1 -0
  17. package/dist/chunk-KCNN4QUQ.js +255 -0
  18. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  19. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  20. package/dist/chunk-NI4EDT4T.js.map +1 -0
  21. package/dist/chunk-Q62VKHIT.js +178 -0
  22. package/dist/chunk-Q62VKHIT.js.map +1 -0
  23. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  24. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  25. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +52 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/commands.js +1 -0
  30. package/dist/definitions.js +1 -0
  31. package/dist/dispatcher.js +1 -0
  32. package/dist/durable-turn.js +3 -2
  33. package/dist/hook-pipeline.js +1 -0
  34. package/dist/index.d.ts +8 -85
  35. package/dist/index.js +249 -134
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop.d.ts +2 -2
  38. package/dist/loop.js +3 -2
  39. package/dist/mcp-bridge.d.ts +3 -3
  40. package/dist/mcp-bridge.js +3 -2
  41. package/dist/model.js +1 -0
  42. package/dist/orchestration.d.ts +84 -0
  43. package/dist/orchestration.js +16 -0
  44. package/dist/orchestration.js.map +1 -0
  45. package/dist/policy.js +1 -0
  46. package/dist/protocol.js +1 -0
  47. package/dist/pulsar.d.ts +78 -0
  48. package/dist/pulsar.js +17 -0
  49. package/dist/pulsar.js.map +1 -0
  50. package/dist/rollout-store-persistent.js +1 -0
  51. package/dist/rollout.js +1 -0
  52. package/dist/sandbox.js +2 -1
  53. package/dist/skills.js +2 -1
  54. package/dist/subagents.js +1 -0
  55. package/dist/tools.d.ts +4 -3
  56. package/dist/tools.js +5 -3
  57. package/package.json +84 -27
  58. package/.turbo/turbo-build.log +0 -115
  59. package/.turbo/turbo-test.log +0 -44
  60. package/.turbo/turbo-typecheck.log +0 -4
  61. package/CHANGELOG.md +0 -253
  62. package/dist/chunk-BJBBR3QA.js.map +0 -1
  63. package/dist/chunk-NN7DATXA.js.map +0 -1
  64. package/dist/chunk-PGGWSUTM.js.map +0 -1
  65. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  66. package/src/adapters/dispatcher-sse.test.ts +0 -218
  67. package/src/adapters/dispatcher-sse.ts +0 -222
  68. package/src/adapters/index.ts +0 -18
  69. package/src/adapters/mcp-catalog.test.ts +0 -213
  70. package/src/adapters/mcp-catalog.ts +0 -188
  71. package/src/adapters/prisma-rollout.test.ts +0 -153
  72. package/src/adapters/prisma-rollout.ts +0 -104
  73. package/src/agent-runtime.test.ts +0 -176
  74. package/src/artifact-stream.test.ts +0 -330
  75. package/src/artifact-stream.ts +0 -453
  76. package/src/channel-gateway.test.ts +0 -432
  77. package/src/channel-gateway.ts +0 -357
  78. package/src/code-review.test.ts +0 -501
  79. package/src/code-review.ts +0 -495
  80. package/src/command-suggestions.test.ts +0 -251
  81. package/src/command-suggestions.ts +0 -338
  82. package/src/commands.test.ts +0 -184
  83. package/src/commands.ts +0 -140
  84. package/src/commit-message.test.ts +0 -249
  85. package/src/commit-message.ts +0 -180
  86. package/src/context-compaction.test.ts +0 -522
  87. package/src/context-compaction.ts +0 -434
  88. package/src/definitions.test.ts +0 -78
  89. package/src/definitions.ts +0 -190
  90. package/src/deployment-status.test.ts +0 -215
  91. package/src/deployment-status.ts +0 -227
  92. package/src/design-context.test.ts +0 -195
  93. package/src/design-context.ts +0 -198
  94. package/src/dispatcher.test.ts +0 -234
  95. package/src/dispatcher.ts +0 -189
  96. package/src/durable-turn.test.ts +0 -209
  97. package/src/durable-turn.ts +0 -135
  98. package/src/edit-planner.test.ts +0 -204
  99. package/src/edit-planner.ts +0 -325
  100. package/src/fuzzy-match.test.ts +0 -311
  101. package/src/fuzzy-match.ts +0 -444
  102. package/src/hook-pipeline.test.ts +0 -279
  103. package/src/hook-pipeline.ts +0 -373
  104. package/src/inbound-admission.test.ts +0 -394
  105. package/src/inbound-admission.ts +0 -246
  106. package/src/index.ts +0 -47
  107. package/src/loop.test.ts +0 -161
  108. package/src/loop.ts +0 -211
  109. package/src/mcp-bridge.test.ts +0 -165
  110. package/src/mcp-bridge.ts +0 -76
  111. package/src/memory-provider.test.ts +0 -232
  112. package/src/memory-provider.ts +0 -257
  113. package/src/model.ts +0 -168
  114. package/src/permission-ruleset.test.ts +0 -301
  115. package/src/permission-ruleset.ts +0 -200
  116. package/src/policy.ts +0 -151
  117. package/src/project-repo.test.ts +0 -232
  118. package/src/project-repo.ts +0 -311
  119. package/src/protocol.ts +0 -159
  120. package/src/rollout-store-persistent.test.ts +0 -217
  121. package/src/rollout-store-persistent.ts +0 -166
  122. package/src/rollout.ts +0 -150
  123. package/src/sandbox.ts +0 -113
  124. package/src/session-share.test.ts +0 -360
  125. package/src/session-share.ts +0 -310
  126. package/src/skill-distillation.test.ts +0 -177
  127. package/src/skill-distillation.ts +0 -369
  128. package/src/skills.test.ts +0 -277
  129. package/src/skills.ts +0 -255
  130. package/src/subagents.test.ts +0 -290
  131. package/src/subagents.ts +0 -332
  132. package/src/tools.ts +0 -126
  133. package/src/workbench.test.ts +0 -0
  134. package/src/workbench.ts +0 -0
  135. package/tsconfig.json +0 -12
  136. package/tsup.config.ts +0 -33
  137. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
@@ -1,394 +0,0 @@
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
- });
@@ -1,246 +0,0 @@
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 DELETED
@@ -1,47 +0,0 @@
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";