@framers/agentos-ext-ml-classifiers 0.1.0 → 0.2.1

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 (51) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/MLClassifierGuardrail.d.ts +88 -117
  3. package/dist/MLClassifierGuardrail.d.ts.map +1 -1
  4. package/dist/MLClassifierGuardrail.js +255 -264
  5. package/dist/MLClassifierGuardrail.js.map +1 -1
  6. package/dist/classifiers/InjectionClassifier.d.ts +1 -1
  7. package/dist/classifiers/InjectionClassifier.d.ts.map +1 -1
  8. package/dist/classifiers/JailbreakClassifier.d.ts +1 -1
  9. package/dist/classifiers/JailbreakClassifier.d.ts.map +1 -1
  10. package/dist/classifiers/ToxicityClassifier.d.ts +1 -1
  11. package/dist/classifiers/ToxicityClassifier.d.ts.map +1 -1
  12. package/dist/classifiers/WorkerClassifierProxy.d.ts +1 -1
  13. package/dist/classifiers/WorkerClassifierProxy.d.ts.map +1 -1
  14. package/dist/index.d.ts +16 -90
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +33 -306
  17. package/dist/index.js.map +1 -1
  18. package/dist/keyword-classifier.d.ts +26 -0
  19. package/dist/keyword-classifier.d.ts.map +1 -0
  20. package/dist/keyword-classifier.js +113 -0
  21. package/dist/keyword-classifier.js.map +1 -0
  22. package/dist/llm-classifier.d.ts +27 -0
  23. package/dist/llm-classifier.d.ts.map +1 -0
  24. package/dist/llm-classifier.js +129 -0
  25. package/dist/llm-classifier.js.map +1 -0
  26. package/dist/tools/ClassifyContentTool.d.ts +53 -80
  27. package/dist/tools/ClassifyContentTool.d.ts.map +1 -1
  28. package/dist/tools/ClassifyContentTool.js +52 -103
  29. package/dist/tools/ClassifyContentTool.js.map +1 -1
  30. package/dist/types.d.ts +77 -277
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js +9 -55
  33. package/dist/types.js.map +1 -1
  34. package/package.json +10 -16
  35. package/src/MLClassifierGuardrail.ts +279 -316
  36. package/src/index.ts +35 -339
  37. package/src/keyword-classifier.ts +130 -0
  38. package/src/llm-classifier.ts +163 -0
  39. package/src/tools/ClassifyContentTool.ts +75 -132
  40. package/src/types.ts +78 -325
  41. package/test/ClassifierOrchestrator.spec.ts +365 -0
  42. package/test/ClassifyContentTool.spec.ts +226 -0
  43. package/test/InjectionClassifier.spec.ts +263 -0
  44. package/test/JailbreakClassifier.spec.ts +295 -0
  45. package/test/MLClassifierGuardrail.spec.ts +486 -0
  46. package/test/SlidingWindowBuffer.spec.ts +391 -0
  47. package/test/ToxicityClassifier.spec.ts +268 -0
  48. package/test/WorkerClassifierProxy.spec.ts +303 -0
  49. package/test/index.spec.ts +431 -0
  50. package/tsconfig.json +20 -0
  51. package/vitest.config.ts +24 -0
@@ -0,0 +1,431 @@
1
+ /**
2
+ * @file index.spec.ts
3
+ * @description Unit tests for the ML Classifier pack factory.
4
+ *
5
+ * Tests verify:
6
+ * - createMLClassifierGuardrail returns an ExtensionPack with name 'ml-classifiers'
7
+ * and version '1.0.0'
8
+ * - The pack provides exactly 2 descriptors: 1 guardrail + 1 tool
9
+ * - Guardrail descriptor has id 'ml-classifier-guardrail' and kind 'guardrail'
10
+ * - Tool descriptor has id 'classify_content' and kind 'tool'
11
+ * - createExtensionPack bridges context.options to createMLClassifierGuardrail
12
+ * - Disabled / selective classifiers work correctly
13
+ * - onActivate rebuilds components with the shared registry
14
+ * - onDeactivate disposes orchestrator and clears buffer
15
+ */
16
+
17
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
18
+ import {
19
+ createMLClassifierGuardrail,
20
+ createExtensionPack,
21
+ } from '../src/index';
22
+ import { SharedServiceRegistry } from '@framers/agentos';
23
+ import {
24
+ EXTENSION_KIND_GUARDRAIL,
25
+ EXTENSION_KIND_TOOL,
26
+ } from '@framers/agentos';
27
+ import type { ExtensionPackContext } from '@framers/agentos';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Mocks — prevent real model downloads and ONNX/WASM loading
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Mock ToxicityClassifier — lightweight stand-in that avoids the real
35
+ * `@huggingface/transformers` import during unit tests.
36
+ */
37
+ vi.mock(
38
+ '../src/classifiers/ToxicityClassifier',
39
+ () => ({
40
+ ToxicityClassifier: vi.fn().mockImplementation(() => ({
41
+ id: 'agentos:ml-classifiers:toxicity-pipeline',
42
+ displayName: 'Toxicity Classifier (mock)',
43
+ description: 'Mock toxicity classifier.',
44
+ modelId: 'unitary/toxic-bert',
45
+ isLoaded: false,
46
+ classify: vi.fn().mockResolvedValue({ bestClass: 'benign', confidence: 0, allScores: [] }),
47
+ dispose: vi.fn().mockResolvedValue(undefined),
48
+ })),
49
+ }),
50
+ );
51
+
52
+ /**
53
+ * Mock InjectionClassifier.
54
+ */
55
+ vi.mock(
56
+ '../src/classifiers/InjectionClassifier',
57
+ () => ({
58
+ InjectionClassifier: vi.fn().mockImplementation(() => ({
59
+ id: 'agentos:ml-classifiers:injection-pipeline',
60
+ displayName: 'Injection Classifier (mock)',
61
+ description: 'Mock injection classifier.',
62
+ modelId: 'protectai/deberta-v3-small-prompt-injection-v2',
63
+ isLoaded: false,
64
+ classify: vi.fn().mockResolvedValue({ bestClass: 'SAFE', confidence: 0.1, allScores: [] }),
65
+ dispose: vi.fn().mockResolvedValue(undefined),
66
+ })),
67
+ }),
68
+ );
69
+
70
+ /**
71
+ * Mock JailbreakClassifier.
72
+ */
73
+ vi.mock(
74
+ '../src/classifiers/JailbreakClassifier',
75
+ () => ({
76
+ JailbreakClassifier: vi.fn().mockImplementation(() => ({
77
+ id: 'agentos:ml-classifiers:jailbreak-pipeline',
78
+ displayName: 'Jailbreak Classifier (mock)',
79
+ description: 'Mock jailbreak classifier.',
80
+ modelId: 'meta-llama/PromptGuard-86M',
81
+ isLoaded: false,
82
+ classify: vi.fn().mockResolvedValue({ bestClass: 'benign', confidence: 0, allScores: [] }),
83
+ dispose: vi.fn().mockResolvedValue(undefined),
84
+ })),
85
+ }),
86
+ );
87
+
88
+ // Import the mocked constructors so tests can assert on them.
89
+ import { ToxicityClassifier } from '../src/classifiers/ToxicityClassifier';
90
+ import { InjectionClassifier } from '../src/classifiers/InjectionClassifier';
91
+ import { JailbreakClassifier } from '../src/classifiers/JailbreakClassifier';
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Tests
95
+ // ---------------------------------------------------------------------------
96
+
97
+ describe('createMLClassifierGuardrail', () => {
98
+ beforeEach(() => {
99
+ vi.clearAllMocks();
100
+ });
101
+
102
+ // -------------------------------------------------------------------------
103
+ // 1. Pack identity
104
+ // -------------------------------------------------------------------------
105
+
106
+ describe('pack identity', () => {
107
+ it('returns an ExtensionPack with name "ml-classifiers"', () => {
108
+ const pack = createMLClassifierGuardrail();
109
+ expect(pack.name).toBe('ml-classifiers');
110
+ });
111
+
112
+ it('returns an ExtensionPack with version "1.0.0"', () => {
113
+ const pack = createMLClassifierGuardrail();
114
+ expect(pack.version).toBe('1.0.0');
115
+ });
116
+ });
117
+
118
+ // -------------------------------------------------------------------------
119
+ // 2. Descriptors shape
120
+ // -------------------------------------------------------------------------
121
+
122
+ describe('descriptors', () => {
123
+ it('provides exactly 2 descriptors', () => {
124
+ const pack = createMLClassifierGuardrail();
125
+ expect(pack.descriptors).toHaveLength(2);
126
+ });
127
+
128
+ it('has a guardrail descriptor with id "ml-classifier-guardrail"', () => {
129
+ const pack = createMLClassifierGuardrail();
130
+ const guardrailDescriptor = pack.descriptors.find((d) => d.id === 'ml-classifier-guardrail');
131
+
132
+ expect(guardrailDescriptor).toBeDefined();
133
+ });
134
+
135
+ it('guardrail descriptor has kind "guardrail"', () => {
136
+ const pack = createMLClassifierGuardrail();
137
+ const guardrailDescriptor = pack.descriptors.find((d) => d.id === 'ml-classifier-guardrail');
138
+
139
+ expect(guardrailDescriptor?.kind).toBe(EXTENSION_KIND_GUARDRAIL);
140
+ });
141
+
142
+ it('guardrail descriptor has priority 5', () => {
143
+ const pack = createMLClassifierGuardrail();
144
+ const guardrailDescriptor = pack.descriptors.find((d) => d.id === 'ml-classifier-guardrail');
145
+
146
+ expect(guardrailDescriptor?.priority).toBe(5);
147
+ });
148
+
149
+ it('guardrail descriptor has a non-null payload', () => {
150
+ const pack = createMLClassifierGuardrail();
151
+ const guardrailDescriptor = pack.descriptors.find((d) => d.id === 'ml-classifier-guardrail');
152
+
153
+ expect(guardrailDescriptor?.payload).toBeDefined();
154
+ expect(guardrailDescriptor?.payload).not.toBeNull();
155
+ });
156
+
157
+ it('has a tool descriptor with id "classify_content"', () => {
158
+ const pack = createMLClassifierGuardrail();
159
+ const toolDescriptor = pack.descriptors.find((d) => d.id === 'classify_content');
160
+
161
+ expect(toolDescriptor).toBeDefined();
162
+ });
163
+
164
+ it('tool descriptor has kind "tool"', () => {
165
+ const pack = createMLClassifierGuardrail();
166
+ const toolDescriptor = pack.descriptors.find((d) => d.id === 'classify_content');
167
+
168
+ expect(toolDescriptor?.kind).toBe(EXTENSION_KIND_TOOL);
169
+ });
170
+
171
+ it('tool descriptor has priority 0', () => {
172
+ const pack = createMLClassifierGuardrail();
173
+ const toolDescriptor = pack.descriptors.find((d) => d.id === 'classify_content');
174
+
175
+ expect(toolDescriptor?.priority).toBe(0);
176
+ });
177
+
178
+ it('tool descriptor has a non-null payload', () => {
179
+ const pack = createMLClassifierGuardrail();
180
+ const toolDescriptor = pack.descriptors.find((d) => d.id === 'classify_content');
181
+
182
+ expect(toolDescriptor?.payload).toBeDefined();
183
+ expect(toolDescriptor?.payload).not.toBeNull();
184
+ });
185
+ });
186
+
187
+ // -------------------------------------------------------------------------
188
+ // 3. Built-in classifier instantiation (zero-config)
189
+ // -------------------------------------------------------------------------
190
+
191
+ describe('zero-config classifier instantiation', () => {
192
+ it('instantiates all three built-in classifiers when no classifiers option is given', () => {
193
+ createMLClassifierGuardrail();
194
+
195
+ // Each built-in classifier should have been constructed once.
196
+ expect(ToxicityClassifier).toHaveBeenCalledOnce();
197
+ expect(InjectionClassifier).toHaveBeenCalledOnce();
198
+ expect(JailbreakClassifier).toHaveBeenCalledOnce();
199
+ });
200
+
201
+ it('instantiates all three built-in classifiers when classifiers is an empty array', () => {
202
+ createMLClassifierGuardrail({ classifiers: [] });
203
+
204
+ expect(ToxicityClassifier).toHaveBeenCalledOnce();
205
+ expect(InjectionClassifier).toHaveBeenCalledOnce();
206
+ expect(JailbreakClassifier).toHaveBeenCalledOnce();
207
+ });
208
+ });
209
+
210
+ // -------------------------------------------------------------------------
211
+ // 4. Selective / disabled classifiers
212
+ // -------------------------------------------------------------------------
213
+
214
+ describe('selective classifiers', () => {
215
+ it('only instantiates ToxicityClassifier when classifiers: ["toxicity"]', () => {
216
+ createMLClassifierGuardrail({ classifiers: ['toxicity'] });
217
+
218
+ expect(ToxicityClassifier).toHaveBeenCalledOnce();
219
+ expect(InjectionClassifier).not.toHaveBeenCalled();
220
+ expect(JailbreakClassifier).not.toHaveBeenCalled();
221
+ });
222
+
223
+ it('only instantiates InjectionClassifier when classifiers: ["injection"]', () => {
224
+ createMLClassifierGuardrail({ classifiers: ['injection'] });
225
+
226
+ expect(ToxicityClassifier).not.toHaveBeenCalled();
227
+ expect(InjectionClassifier).toHaveBeenCalledOnce();
228
+ expect(JailbreakClassifier).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it('only instantiates JailbreakClassifier when classifiers: ["jailbreak"]', () => {
232
+ createMLClassifierGuardrail({ classifiers: ['jailbreak'] });
233
+
234
+ expect(ToxicityClassifier).not.toHaveBeenCalled();
235
+ expect(InjectionClassifier).not.toHaveBeenCalled();
236
+ expect(JailbreakClassifier).toHaveBeenCalledOnce();
237
+ });
238
+
239
+ it('instantiates toxicity and jailbreak but not injection when specified', () => {
240
+ createMLClassifierGuardrail({ classifiers: ['toxicity', 'jailbreak'] });
241
+
242
+ expect(ToxicityClassifier).toHaveBeenCalledOnce();
243
+ expect(InjectionClassifier).not.toHaveBeenCalled();
244
+ expect(JailbreakClassifier).toHaveBeenCalledOnce();
245
+ });
246
+
247
+ it('still provides 2 descriptors when only 1 classifier is enabled', () => {
248
+ const pack = createMLClassifierGuardrail({ classifiers: ['toxicity'] });
249
+
250
+ // The guardrail and tool are always present regardless of classifier count.
251
+ expect(pack.descriptors).toHaveLength(2);
252
+ });
253
+ });
254
+
255
+ // -------------------------------------------------------------------------
256
+ // 5. Custom classifiers
257
+ // -------------------------------------------------------------------------
258
+
259
+ describe('customClassifiers option', () => {
260
+ it('includes custom classifiers alongside built-in ones', () => {
261
+ const customClassifier = {
262
+ id: 'custom:sarcasm',
263
+ displayName: 'Sarcasm Detector',
264
+ description: 'Detects sarcasm.',
265
+ modelId: 'my-org/sarcasm-bert',
266
+ isLoaded: false,
267
+ classify: vi.fn().mockResolvedValue({ bestClass: 'benign', confidence: 0, allScores: [] }),
268
+ };
269
+
270
+ // Should not throw when a custom classifier is provided.
271
+ const pack = createMLClassifierGuardrail({
272
+ classifiers: ['toxicity'],
273
+ customClassifiers: [customClassifier],
274
+ });
275
+
276
+ // Pack structure must remain consistent.
277
+ expect(pack.descriptors).toHaveLength(2);
278
+ });
279
+ });
280
+
281
+ // -------------------------------------------------------------------------
282
+ // 6. onActivate lifecycle hook
283
+ // -------------------------------------------------------------------------
284
+
285
+ describe('onActivate lifecycle hook', () => {
286
+ it('rebuilds components when onActivate is called with a shared registry', () => {
287
+ const pack = createMLClassifierGuardrail();
288
+
289
+ // Record the number of classifier constructions at pack-creation time.
290
+ const constructsBefore =
291
+ (ToxicityClassifier as ReturnType<typeof vi.fn>).mock.calls.length +
292
+ (InjectionClassifier as ReturnType<typeof vi.fn>).mock.calls.length +
293
+ (JailbreakClassifier as ReturnType<typeof vi.fn>).mock.calls.length;
294
+
295
+ // Activate with a shared registry.
296
+ const sharedRegistry = new SharedServiceRegistry();
297
+ pack.onActivate!({ services: sharedRegistry });
298
+
299
+ const constructsAfter =
300
+ (ToxicityClassifier as ReturnType<typeof vi.fn>).mock.calls.length +
301
+ (InjectionClassifier as ReturnType<typeof vi.fn>).mock.calls.length +
302
+ (JailbreakClassifier as ReturnType<typeof vi.fn>).mock.calls.length;
303
+
304
+ // Activation must have rebuilt the classifiers (3 more constructions).
305
+ expect(constructsAfter).toBe(constructsBefore + 3);
306
+ });
307
+
308
+ it('descriptors still reflect the rebuilt components after onActivate', () => {
309
+ const pack = createMLClassifierGuardrail();
310
+ const sharedRegistry = new SharedServiceRegistry();
311
+ pack.onActivate!({ services: sharedRegistry });
312
+
313
+ // Descriptors getter must return fresh references.
314
+ expect(pack.descriptors).toHaveLength(2);
315
+ });
316
+
317
+ it('does not throw when onActivate is called without services', () => {
318
+ const pack = createMLClassifierGuardrail();
319
+
320
+ // Context without a services field should be handled gracefully.
321
+ expect(() => pack.onActivate!({})).not.toThrow();
322
+ });
323
+ });
324
+
325
+ // -------------------------------------------------------------------------
326
+ // 7. onDeactivate lifecycle hook
327
+ // -------------------------------------------------------------------------
328
+
329
+ describe('onDeactivate lifecycle hook', () => {
330
+ it('resolves without throwing', async () => {
331
+ const pack = createMLClassifierGuardrail();
332
+ await expect(pack.onDeactivate!()).resolves.toBeUndefined();
333
+ });
334
+ });
335
+
336
+ // -------------------------------------------------------------------------
337
+ // 8. Options passthrough
338
+ // -------------------------------------------------------------------------
339
+
340
+ describe('options passthrough', () => {
341
+ it('accepts and applies streaming mode options without throwing', () => {
342
+ expect(() =>
343
+ createMLClassifierGuardrail({
344
+ streamingMode: true,
345
+ chunkSize: 150,
346
+ contextSize: 30,
347
+ maxEvaluations: 50,
348
+ guardrailScope: 'output',
349
+ }),
350
+ ).not.toThrow();
351
+ });
352
+
353
+ it('accepts custom thresholds without throwing', () => {
354
+ expect(() =>
355
+ createMLClassifierGuardrail({
356
+ thresholds: {
357
+ blockThreshold: 0.95,
358
+ flagThreshold: 0.75,
359
+ warnThreshold: 0.5,
360
+ },
361
+ }),
362
+ ).not.toThrow();
363
+ });
364
+ });
365
+ });
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // createExtensionPack (manifest factory bridge)
369
+ // ---------------------------------------------------------------------------
370
+
371
+ describe('createExtensionPack', () => {
372
+ beforeEach(() => {
373
+ vi.clearAllMocks();
374
+ });
375
+
376
+ it('returns a pack with name "ml-classifiers"', () => {
377
+ const context: ExtensionPackContext = {};
378
+ const pack = createExtensionPack(context);
379
+
380
+ expect(pack.name).toBe('ml-classifiers');
381
+ });
382
+
383
+ it('returns a pack with version "1.0.0"', () => {
384
+ const context: ExtensionPackContext = {};
385
+ const pack = createExtensionPack(context);
386
+
387
+ expect(pack.version).toBe('1.0.0');
388
+ });
389
+
390
+ it('provides 2 descriptors with empty context', () => {
391
+ const pack = createExtensionPack({});
392
+
393
+ expect(pack.descriptors).toHaveLength(2);
394
+ });
395
+
396
+ it('bridges context.options to createMLClassifierGuardrail — classifiers subset', () => {
397
+ const context: ExtensionPackContext = {
398
+ options: {
399
+ classifiers: ['toxicity'],
400
+ },
401
+ };
402
+
403
+ createExtensionPack(context);
404
+
405
+ // Only ToxicityClassifier should have been instantiated.
406
+ expect(ToxicityClassifier).toHaveBeenCalledOnce();
407
+ expect(InjectionClassifier).not.toHaveBeenCalled();
408
+ expect(JailbreakClassifier).not.toHaveBeenCalled();
409
+ });
410
+
411
+ it('bridges context.options to createMLClassifierGuardrail — thresholds', () => {
412
+ const context: ExtensionPackContext = {
413
+ options: {
414
+ thresholds: { blockThreshold: 0.99 },
415
+ },
416
+ };
417
+
418
+ const pack = createExtensionPack(context);
419
+
420
+ // Pack must still be well-formed.
421
+ expect(pack.descriptors).toHaveLength(2);
422
+ });
423
+
424
+ it('works when context.options is undefined', () => {
425
+ const context: ExtensionPackContext = { options: undefined };
426
+ const pack = createExtensionPack(context);
427
+
428
+ expect(pack.name).toBe('ml-classifiers');
429
+ expect(pack.descriptors).toHaveLength(2);
430
+ });
431
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true
17
+ },
18
+ "include": ["src/**/*.ts"],
19
+ "exclude": ["node_modules", "dist", "test"]
20
+ }
@@ -0,0 +1,24 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+
5
+ // CI layout: agentos cloned into packages/agentos/ inside this repo
6
+ const ciPath = path.resolve(__dirname, '../../../../packages/agentos/src');
7
+ // Monorepo layout: agentos is a sibling at packages/agentos/
8
+ const monoPath = path.resolve(__dirname, '../../../../../agentos/src');
9
+
10
+ const agentosPath = fs.existsSync(ciPath) ? ciPath : fs.existsSync(monoPath) ? monoPath : null;
11
+
12
+ export default defineConfig({
13
+ test: {
14
+ globals: true,
15
+ environment: 'node',
16
+ include: ['test/**/*.spec.ts'],
17
+ testTimeout: 10000,
18
+ },
19
+ resolve: agentosPath ? {
20
+ alias: {
21
+ '@framers/agentos': agentosPath,
22
+ },
23
+ } : {},
24
+ });