@framers/agentos-ext-topicality 0.1.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/src/index.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * @fileoverview Pack factory for the Topicality Guardrail Extension Pack.
3
+ *
4
+ * Exports the main `createTopicalityPack()` factory that assembles the
5
+ * {@link TopicalityGuardrail} and the {@link CheckTopicTool} into a single
6
+ * {@link ExtensionPack} ready for registration with the AgentOS extension
7
+ * manager.
8
+ *
9
+ * Also exports a `createExtensionPack()` bridge function that conforms to
10
+ * the AgentOS manifest factory convention, delegating to
11
+ * `createTopicalityPack()` with options extracted from the
12
+ * {@link ExtensionPackContext}.
13
+ *
14
+ * ### Default behaviour (zero-config)
15
+ * When called without arguments, no topics are configured so the guardrail
16
+ * and tool are effectively no-ops. Callers should provide at least
17
+ * `allowedTopics` or `forbiddenTopics` for meaningful enforcement.
18
+ *
19
+ * ### Activation lifecycle
20
+ * Components are built eagerly at pack creation time for direct programmatic
21
+ * use. When the extension manager activates the pack, `onActivate` rebuilds
22
+ * all components with the manager's shared service registry so heavyweight
23
+ * resources (embedding models) are shared across the agent.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * import { createTopicalityPack, TOPIC_PRESETS } from './topicality';
28
+ *
29
+ * const pack = createTopicalityPack({
30
+ * allowedTopics: TOPIC_PRESETS.customerSupport,
31
+ * forbiddenTopics: TOPIC_PRESETS.commonUnsafe,
32
+ * });
33
+ * ```
34
+ *
35
+ * @module agentos/extensions/packs/topicality
36
+ */
37
+
38
+ import type { ISharedServiceRegistry } from '@framers/agentos';
39
+ import { SharedServiceRegistry } from '@framers/agentos';
40
+ import type { ExtensionPack, ExtensionPackContext } from '@framers/agentos';
41
+ import type { ExtensionDescriptor, ExtensionLifecycleContext } from '@framers/agentos';
42
+ import { EXTENSION_KIND_GUARDRAIL, EXTENSION_KIND_TOOL } from '@framers/agentos';
43
+ import type { TopicalityPackOptions } from './types';
44
+ import { TopicalityGuardrail } from './TopicalityGuardrail';
45
+ import { CheckTopicTool } from './tools/CheckTopicTool';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Re-exports — allow single-import for consumers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Re-export all types from the topicality type definitions so consumers
53
+ * can import everything from a single entry point:
54
+ * ```ts
55
+ * import { createTopicalityPack, TOPIC_PRESETS } from './topicality';
56
+ * ```
57
+ */
58
+ export * from './types';
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Pack factory
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Create an {@link ExtensionPack} that bundles:
66
+ * - The {@link TopicalityGuardrail} guardrail (evaluates input & output
67
+ * against allowed/forbidden topics and drift detection).
68
+ * - The {@link CheckTopicTool} `check_topic` tool (on-demand topic analysis).
69
+ *
70
+ * @param options - Optional pack-level configuration. All properties have
71
+ * sensible defaults; see {@link TopicalityPackOptions}.
72
+ * @returns A fully-configured {@link ExtensionPack} with one guardrail
73
+ * descriptor and one tool descriptor.
74
+ */
75
+ export function createTopicalityPack(options?: TopicalityPackOptions): ExtensionPack {
76
+ /**
77
+ * Resolved options — default to empty object so every sub-check can
78
+ * safely use `opts.foo` without null-guarding the whole `options` reference.
79
+ */
80
+ const opts: TopicalityPackOptions = options ?? {};
81
+
82
+ // -------------------------------------------------------------------------
83
+ // Mutable state — upgraded by onActivate with the extension manager's
84
+ // shared service registry.
85
+ // -------------------------------------------------------------------------
86
+
87
+ const state = {
88
+ /**
89
+ * Service registry — starts as a standalone instance so the pack can be
90
+ * used directly (without activation) in unit tests and scripts.
91
+ * Replaced with the shared registry when `onActivate` is called by the
92
+ * extension manager.
93
+ */
94
+ services: new SharedServiceRegistry() as ISharedServiceRegistry,
95
+ };
96
+
97
+ // -------------------------------------------------------------------------
98
+ // Component instances — rebuilt by buildComponents()
99
+ // -------------------------------------------------------------------------
100
+
101
+ /**
102
+ * The guardrail that evaluates user input and/or agent output against
103
+ * configured allowed and forbidden topic sets.
104
+ */
105
+ let guardrail: TopicalityGuardrail;
106
+
107
+ /**
108
+ * The on-demand topic checking tool exposed to agents and workflows.
109
+ */
110
+ let tool: CheckTopicTool;
111
+
112
+ // -------------------------------------------------------------------------
113
+ // Embedding function resolution
114
+ // -------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Resolves the embedding function to use for topic matching.
118
+ *
119
+ * Priority:
120
+ * 1. Explicit `opts.embeddingFn` provided by the caller.
121
+ * 2. Fallback to the shared service registry's EmbeddingManager.
122
+ *
123
+ * @returns An async embedding function.
124
+ */
125
+ function resolveEmbeddingFn(): (texts: string[]) => Promise<number[][]> {
126
+ if (opts.embeddingFn) {
127
+ return opts.embeddingFn;
128
+ }
129
+
130
+ // Fallback: request an EmbeddingManager from the shared service registry
131
+ // at call time (lazy resolution).
132
+ return async (texts: string[]): Promise<number[][]> => {
133
+ const em = await state.services.getOrCreate<{
134
+ generateEmbeddings: (texts: string[]) => Promise<number[][]>;
135
+ }>(
136
+ 'agentos:topicality:embedding-manager',
137
+ async () => {
138
+ throw new Error(
139
+ 'EmbeddingManager not available in shared service registry. ' +
140
+ 'Provide an explicit embeddingFn in TopicalityPackOptions or ' +
141
+ 'register an EmbeddingManager before activating the topicality pack.',
142
+ );
143
+ },
144
+ );
145
+ return em.generateEmbeddings(texts);
146
+ };
147
+ }
148
+
149
+ // -------------------------------------------------------------------------
150
+ // buildComponents
151
+ // -------------------------------------------------------------------------
152
+
153
+ /**
154
+ * (Re)construct all pack components using the current `state.services`.
155
+ *
156
+ * Called once at pack creation for direct programmatic use, and again
157
+ * during `onActivate` to upgrade to the extension manager's shared
158
+ * service registry.
159
+ */
160
+ function buildComponents(): void {
161
+ const embeddingFn = resolveEmbeddingFn();
162
+
163
+ // Resolve thresholds with defaults.
164
+ const allowedThreshold = opts.allowedThreshold ?? 0.35;
165
+ const forbiddenThreshold = opts.forbiddenThreshold ?? 0.65;
166
+
167
+ // ------------------------------------------------------------------
168
+ // 1. Build the guardrail.
169
+ // ------------------------------------------------------------------
170
+ guardrail = new TopicalityGuardrail(state.services, opts, embeddingFn);
171
+
172
+ // ------------------------------------------------------------------
173
+ // 2. Build the on-demand topic checking tool.
174
+ // The tool starts with null indices — the guardrail builds them
175
+ // lazily, and the tool shares the same embeddingFn so it can
176
+ // operate independently.
177
+ // ------------------------------------------------------------------
178
+ tool = new CheckTopicTool(
179
+ null, // allowedIndex — will be null until lazy build
180
+ null, // forbiddenIndex — will be null until lazy build
181
+ embeddingFn,
182
+ allowedThreshold,
183
+ forbiddenThreshold,
184
+ );
185
+
186
+ }
187
+
188
+ // Initial build — makes the pack usable immediately without activation.
189
+ buildComponents();
190
+
191
+ // -------------------------------------------------------------------------
192
+ // ExtensionPack shape
193
+ // -------------------------------------------------------------------------
194
+
195
+ return {
196
+ /** Canonical pack name used in manifests and logs. */
197
+ name: 'topicality',
198
+
199
+ /** Semantic version of this pack implementation. */
200
+ version: '1.0.0',
201
+
202
+ /**
203
+ * Descriptor getter — always returns the latest (possibly rebuilt)
204
+ * component instances. Using a getter ensures that after `onActivate`
205
+ * rebuilds the components, the descriptors array reflects the new
206
+ * references rather than stale closures from the initial build.
207
+ */
208
+ get descriptors(): ExtensionDescriptor[] {
209
+ return [
210
+ {
211
+ /**
212
+ * Guardrail descriptor.
213
+ *
214
+ * Priority 3 places this guardrail early in the pipeline so
215
+ * topic enforcement happens before most other guardrails.
216
+ */
217
+ id: 'topicality-guardrail',
218
+ kind: EXTENSION_KIND_GUARDRAIL,
219
+ priority: 3,
220
+ payload: guardrail,
221
+ },
222
+ {
223
+ /**
224
+ * On-demand topic checking tool descriptor.
225
+ *
226
+ * Priority 0 uses the default ordering — tools are typically
227
+ * ordered by name rather than priority.
228
+ */
229
+ id: 'check_topic',
230
+ kind: EXTENSION_KIND_TOOL,
231
+ priority: 0,
232
+ payload: tool,
233
+ },
234
+ ];
235
+ },
236
+
237
+ /**
238
+ * Lifecycle hook called by the extension manager when the pack is
239
+ * activated.
240
+ *
241
+ * Upgrades the internal service registry to the extension manager's
242
+ * shared instance (so embedding models are shared across all
243
+ * extensions) then rebuilds all components to use the new registry.
244
+ *
245
+ * @param context - Activation context provided by the extension manager.
246
+ */
247
+ onActivate: (context: ExtensionLifecycleContext): void => {
248
+ // Upgrade to the shared registry when the manager provides one.
249
+ if (context.services) {
250
+ state.services = context.services;
251
+ }
252
+
253
+ // Rebuild all components with the upgraded registry.
254
+ buildComponents();
255
+ },
256
+
257
+ /**
258
+ * Lifecycle hook called when the pack is deactivated or the agent shuts
259
+ * down.
260
+ *
261
+ * Clears drift tracker session state to release memory.
262
+ */
263
+ onDeactivate: async (): Promise<void> => {
264
+ guardrail.clearSessionState();
265
+ },
266
+ };
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Manifest factory bridge
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * AgentOS manifest factory function.
275
+ *
276
+ * Conforms to the convention expected by the extension loader when resolving
277
+ * packs from manifests. Extracts `options` from the {@link ExtensionPackContext}
278
+ * and delegates to {@link createTopicalityPack}.
279
+ *
280
+ * @param context - Manifest context containing optional pack options, secret
281
+ * resolver, and shared service registry.
282
+ * @returns A fully-configured {@link ExtensionPack}.
283
+ *
284
+ * @example Manifest entry:
285
+ * ```json
286
+ * {
287
+ * "packs": [
288
+ * {
289
+ * "module": "./topicality",
290
+ * "options": {
291
+ * "allowedTopics": [...],
292
+ * "forbiddenTopics": [...],
293
+ * "allowedThreshold": 0.4
294
+ * }
295
+ * }
296
+ * ]
297
+ * }
298
+ * ```
299
+ */
300
+ export function createExtensionPack(context: ExtensionPackContext): ExtensionPack {
301
+ return createTopicalityPack(context.options as TopicalityPackOptions);
302
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * @fileoverview On-demand topic checking tool for the topicality extension pack.
3
+ *
4
+ * `CheckTopicTool` implements {@link ITool} and exposes a `check_topic`
5
+ * function that agents and workflows can invoke to determine whether a
6
+ * piece of text aligns with the configured allowed topics, matches any
7
+ * forbidden topics, or both.
8
+ *
9
+ * Unlike the {@link TopicalityGuardrail} (which runs automatically on every
10
+ * message), this tool is invoked explicitly and returns structured data
11
+ * rather than triggering block/flag actions. It is useful for:
12
+ *
13
+ * - Agent self-awareness ("Am I still on-topic?")
14
+ * - User-facing topic suggestions ("Your question is closest to: Billing")
15
+ * - Workflow branching ("If off-topic, route to fallback handler")
16
+ *
17
+ * @module topicality/tools/CheckTopicTool
18
+ */
19
+
20
+ import type {
21
+ ITool,
22
+ JSONSchemaObject,
23
+ ToolExecutionContext,
24
+ ToolExecutionResult,
25
+ } from '@framers/agentos';
26
+ import type { TopicEmbeddingIndex } from '../TopicEmbeddingIndex';
27
+ import type { TopicMatch } from '../types';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Input / output types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Input arguments accepted by the `check_topic` tool.
35
+ */
36
+ interface CheckTopicInput {
37
+ /** The text string to evaluate against configured topics. */
38
+ text: string;
39
+ }
40
+
41
+ /**
42
+ * Structured output returned by the `check_topic` tool on success.
43
+ */
44
+ interface CheckTopicOutput {
45
+ /**
46
+ * Whether the text is considered on-topic (i.e., above the allowed
47
+ * threshold against at least one allowed topic). `null` if no
48
+ * allowed topics are configured.
49
+ */
50
+ onTopic: boolean | null;
51
+
52
+ /**
53
+ * The allowed topic with the highest similarity to the input text,
54
+ * or `null` if no allowed topics are configured.
55
+ */
56
+ nearestTopic: TopicMatch | null;
57
+
58
+ /**
59
+ * The forbidden topic with the highest similarity to the input text,
60
+ * or `null` if no forbidden topics are configured or none matched.
61
+ * Only includes matches above the forbidden threshold.
62
+ */
63
+ forbiddenMatch: TopicMatch | null;
64
+
65
+ /**
66
+ * Full list of similarity scores against all configured topics
67
+ * (both allowed and forbidden), sorted descending by similarity.
68
+ */
69
+ allScores: TopicMatch[];
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // CheckTopicTool
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * On-demand topic classification tool.
78
+ *
79
+ * Embeds the input text and checks it against both allowed and forbidden
80
+ * topic indices, returning structured similarity data.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const result = await tool.execute(
85
+ * { text: 'How do I cancel my subscription?' },
86
+ * executionContext,
87
+ * );
88
+ * // result.output.onTopic → true
89
+ * // result.output.nearestTopic → { topicId: 'billing', ... }
90
+ * ```
91
+ */
92
+ export class CheckTopicTool implements ITool<CheckTopicInput, CheckTopicOutput> {
93
+ // -------------------------------------------------------------------------
94
+ // ITool metadata
95
+ // -------------------------------------------------------------------------
96
+
97
+ /** @inheritdoc */
98
+ readonly id = 'check_topic';
99
+
100
+ /** @inheritdoc */
101
+ readonly name = 'check_topic';
102
+
103
+ /** @inheritdoc */
104
+ readonly displayName = 'Topic Checker';
105
+
106
+ /** @inheritdoc */
107
+ readonly description =
108
+ 'Checks whether a piece of text aligns with configured allowed topics or ' +
109
+ 'matches any forbidden topics. Returns similarity scores and the nearest ' +
110
+ 'topic match. Useful for agent self-awareness and workflow branching.';
111
+
112
+ /** @inheritdoc */
113
+ readonly category = 'security';
114
+
115
+ /** @inheritdoc */
116
+ readonly version = '1.0.0';
117
+
118
+ /** @inheritdoc */
119
+ readonly hasSideEffects = false;
120
+
121
+ /** @inheritdoc */
122
+ readonly inputSchema: JSONSchemaObject = {
123
+ type: 'object',
124
+ properties: {
125
+ text: {
126
+ type: 'string',
127
+ description: 'The text to evaluate against configured topics.',
128
+ },
129
+ },
130
+ required: ['text'],
131
+ additionalProperties: false,
132
+ };
133
+
134
+ // -------------------------------------------------------------------------
135
+ // Private state
136
+ // -------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Embedding index for allowed topics. May be `null` if no allowed
140
+ * topics are configured.
141
+ */
142
+ private allowedIndex: TopicEmbeddingIndex | null;
143
+
144
+ /**
145
+ * Embedding index for forbidden topics. May be `null` if no forbidden
146
+ * topics are configured.
147
+ */
148
+ private forbiddenIndex: TopicEmbeddingIndex | null;
149
+
150
+ /**
151
+ * Embedding function used to convert input text to a numeric vector.
152
+ */
153
+ private readonly embeddingFn: (texts: string[]) => Promise<number[][]>;
154
+
155
+ /**
156
+ * Minimum similarity score against an allowed topic for the text to
157
+ * be considered on-topic.
158
+ */
159
+ private readonly allowedThreshold: number;
160
+
161
+ /**
162
+ * Similarity score above which a forbidden topic match is reported.
163
+ */
164
+ private readonly forbiddenThreshold: number;
165
+
166
+ // -------------------------------------------------------------------------
167
+ // Constructor
168
+ // -------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Creates a new `CheckTopicTool`.
172
+ *
173
+ * @param allowedIndex - Pre-built index of allowed topics (or `null`).
174
+ * @param forbiddenIndex - Pre-built index of forbidden topics (or `null`).
175
+ * @param embeddingFn - Async function to embed text strings.
176
+ * @param allowedThreshold - Minimum similarity for on-topic classification.
177
+ * @param forbiddenThreshold - Minimum similarity for forbidden topic flagging.
178
+ */
179
+ constructor(
180
+ allowedIndex: TopicEmbeddingIndex | null,
181
+ forbiddenIndex: TopicEmbeddingIndex | null,
182
+ embeddingFn: (texts: string[]) => Promise<number[][]>,
183
+ allowedThreshold: number,
184
+ forbiddenThreshold: number,
185
+ ) {
186
+ this.allowedIndex = allowedIndex;
187
+ this.forbiddenIndex = forbiddenIndex;
188
+ this.embeddingFn = embeddingFn;
189
+ this.allowedThreshold = allowedThreshold;
190
+ this.forbiddenThreshold = forbiddenThreshold;
191
+ }
192
+
193
+ // -------------------------------------------------------------------------
194
+ // Index setters (used by pack factory on rebuild)
195
+ // -------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Replaces the allowed topic index. Called by the pack factory when
199
+ * components are rebuilt (e.g., after `onActivate`).
200
+ *
201
+ * @param index - The new allowed topic index (or `null` to clear).
202
+ */
203
+ setAllowedIndex(index: TopicEmbeddingIndex | null): void {
204
+ this.allowedIndex = index;
205
+ }
206
+
207
+ /**
208
+ * Replaces the forbidden topic index. Called by the pack factory when
209
+ * components are rebuilt.
210
+ *
211
+ * @param index - The new forbidden topic index (or `null` to clear).
212
+ */
213
+ setForbiddenIndex(index: TopicEmbeddingIndex | null): void {
214
+ this.forbiddenIndex = index;
215
+ }
216
+
217
+ // -------------------------------------------------------------------------
218
+ // ITool — execute
219
+ // -------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Embeds the input text and evaluates it against allowed and forbidden
223
+ * topic indices.
224
+ *
225
+ * @param args - Input arguments containing the `text` field.
226
+ * @param context - Tool execution context (not used by this tool).
227
+ * @returns A {@link ToolExecutionResult} containing the structured
228
+ * topic analysis or an error message.
229
+ */
230
+ async execute(
231
+ args: CheckTopicInput,
232
+ context: ToolExecutionContext,
233
+ ): Promise<ToolExecutionResult<CheckTopicOutput>> {
234
+ // Validate input.
235
+ if (!args.text || args.text.trim().length === 0) {
236
+ return {
237
+ success: false,
238
+ error: 'The "text" field is required and must be a non-empty string.',
239
+ };
240
+ }
241
+
242
+ try {
243
+ // Embed the input text once.
244
+ const [embedding] = await this.embeddingFn([args.text]);
245
+
246
+ // Collect all scores from both indices.
247
+ const allScores: TopicMatch[] = [];
248
+
249
+ // --- Allowed topics ---
250
+ let onTopic: boolean | null = null;
251
+ let nearestTopic: TopicMatch | null = null;
252
+
253
+ if (this.allowedIndex) {
254
+ const allowedMatches = this.allowedIndex.matchByVector(embedding);
255
+ allScores.push(...allowedMatches);
256
+
257
+ // Determine if on-topic using the threshold.
258
+ onTopic = this.allowedIndex.isOnTopicByVector(embedding, this.allowedThreshold);
259
+
260
+ // The nearest topic is the first match (highest similarity).
261
+ nearestTopic = allowedMatches.length > 0 ? allowedMatches[0] : null;
262
+ }
263
+
264
+ // --- Forbidden topics ---
265
+ let forbiddenMatch: TopicMatch | null = null;
266
+
267
+ if (this.forbiddenIndex) {
268
+ const forbiddenMatches = this.forbiddenIndex.matchByVector(embedding);
269
+ allScores.push(...forbiddenMatches);
270
+
271
+ // Report the highest-scoring forbidden match if above threshold.
272
+ if (forbiddenMatches.length > 0 && forbiddenMatches[0].similarity > this.forbiddenThreshold) {
273
+ forbiddenMatch = forbiddenMatches[0];
274
+ }
275
+ }
276
+
277
+ // Sort all scores descending by similarity.
278
+ allScores.sort((a, b) => b.similarity - a.similarity);
279
+
280
+ return {
281
+ success: true,
282
+ output: {
283
+ onTopic,
284
+ nearestTopic,
285
+ forbiddenMatch,
286
+ allScores,
287
+ },
288
+ };
289
+ } catch (error) {
290
+ return {
291
+ success: false,
292
+ error: `Topic check failed: ${error instanceof Error ? error.message : String(error)}`,
293
+ };
294
+ }
295
+ }
296
+ }