@axiom-lattice/core 2.1.76 → 2.1.77

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/README.md CHANGED
@@ -130,3 +130,261 @@ registerChunkBuffer("default", buffer);
130
130
  - `src/memory_lattice/` — Context/memory management
131
131
  - `src/schedule_lattice/` — Scheduled task management
132
132
  - `src/sandbox_lattice/` — Code execution sandbox providers
133
+
134
+ ---
135
+
136
+ ## Custom Middleware Registry
137
+
138
+ Applications can write their own middleware and register it at runtime without modifying core source code. The registered middleware can then be enabled via agent config stored in the database.
139
+
140
+ ### Quick start
141
+
142
+ ```typescript
143
+ import { createMiddleware, CustomMiddlewareRegistry } from "@axiom-lattice/core";
144
+
145
+ CustomMiddlewareRegistry.register("my-logger", (config) =>
146
+ createMiddleware({
147
+ name: "MyLogger",
148
+ wrapModelCall: async (request, handler) => {
149
+ console.log(`[${config.logLevel}] Model call`);
150
+ return handler(request);
151
+ },
152
+ })
153
+ );
154
+ ```
155
+
156
+ ### Database config format
157
+
158
+ In your agent's `graphDefinition.middleware` array, add a `"custom"` type entry:
159
+
160
+ ```json
161
+ {
162
+ "id": "mw-001",
163
+ "type": "custom",
164
+ "name": "Audit Logger",
165
+ "description": "Logs model calls for audit",
166
+ "enabled": true,
167
+ "config": {
168
+ "key": "my-logger",
169
+ "logLevel": "debug"
170
+ }
171
+ }
172
+ ```
173
+
174
+ The `config.key` must match the key passed to `register()`. All other config fields are forwarded to your factory function.
175
+
176
+ ### Full example — permission-check middleware
177
+
178
+ ```typescript
179
+ import { createMiddleware, CustomMiddlewareRegistry } from "@axiom-lattice/core";
180
+
181
+ CustomMiddlewareRegistry.register("permission-check", (config) =>
182
+ createMiddleware({
183
+ name: "PermissionCheck",
184
+ wrapToolCall: async (request, handler) => {
185
+ const toolName = request.toolCall?.name;
186
+ if (toolName && !config.allowedTools.includes(toolName)) {
187
+ return { content: `Tool "${toolName}" is not permitted.` };
188
+ }
189
+ return handler(request);
190
+ },
191
+ })
192
+ );
193
+ ```
194
+
195
+ Database config:
196
+
197
+ ```json
198
+ {
199
+ "id": "perm-001",
200
+ "type": "custom",
201
+ "name": "Tool Permissions",
202
+ "description": "Restrict tool access",
203
+ "enabled": true,
204
+ "config": {
205
+ "key": "permission-check",
206
+ "allowedTools": ["read_file", "write_file"]
207
+ }
208
+ }
209
+ ```
210
+
211
+ ### API reference
212
+
213
+ | Method | Signature | Description |
214
+ |--------|-----------|-------------|
215
+ | `register` | `(key: string, factory: (config: Record<string, any>) => AgentMiddleware \| Promise<AgentMiddleware>) => void` | Register a factory by key |
216
+ | `unregister` | `(key: string) => boolean` | Remove a registration |
217
+ | `get` | `(key: string) => AgentMiddlewareFactory \| undefined` | Look up a factory |
218
+ | `has` | `(key: string) => boolean` | Check if key is registered |
219
+ | `list` | `() => string[]` | Get all registered keys |
220
+
221
+ ### Important
222
+
223
+ - Register factories **before** building agents (typically at app startup)
224
+ - Duplicate keys overwrite previous registrations
225
+ - Unregistered keys are skipped with a console warning at build time
226
+ - Factory functions receive the raw `config` object (minus `key`) — validate it yourself
227
+
228
+ ---
229
+
230
+ ## External Channel Integration
231
+
232
+ core provides the storage infrastructure and binding system that external channel adapters (Lark, Slack, Email, etc.) depend on. The actual HTTP routes and message dispatch live in `packages/gateway`, but all persistence and sender resolution are handled here.
233
+
234
+ ### What core provides
235
+
236
+ | Component | Location | Purpose |
237
+ |-----------|----------|---------|
238
+ | `InMemoryChannelInstallationStore` | `store_lattice/` | In-memory `ChannelInstallationStore` implementation |
239
+ | `InMemoryBindingStore` | `store_lattice/` | In-memory `BindingRegistry` implementation |
240
+ | `BindingRegistryHolder` | `bindings_lattice/` | Global accessor for the active `BindingRegistry` |
241
+ | `manage_binding` tool | `tool_lattice/manage_binding/` | Agent tool for CRUD on sender-to-agent bindings |
242
+
243
+ ### Architecture overview
244
+
245
+ ```
246
+ External Platform (Lark, Slack, Email)
247
+ → Gateway HTTP route (packages/gateway)
248
+ → ChannelAdapter.receive(rawPayload) → InboundMessage
249
+ → MessageRouter.dispatch() (packages/gateway)
250
+ → BindingRegistry.resolve() ←── core
251
+ → Thread creation / reuse ←── core
252
+ → Agent.addMessage() ←── core
253
+ → ChannelAdapter.sendReply() (packages/gateway)
254
+ ```
255
+
256
+ Agents remain **channel-agnostic** — they only see standard thread/message execution.
257
+
258
+ ### Setup for custom channel development
259
+
260
+ #### 1. Register channel stores
261
+
262
+ ```typescript
263
+ import { configureStores } from "@axiom-lattice/core";
264
+ import { PostgreSQLChannelInstallationStore, PostgreSQLBindingStore } from "@axiom-lattice/pg-stores";
265
+
266
+ await configureStores({
267
+ channelInstallation: new PostgreSQLChannelInstallationStore({ pool }),
268
+ channelBinding: new PostgreSQLBindingStore({ pool }),
269
+ // ... other stores
270
+ });
271
+ ```
272
+
273
+ #### 2. Set the global BindingRegistry
274
+
275
+ ```typescript
276
+ import { setBindingRegistry } from "@axiom-lattice/core";
277
+
278
+ const bindingStore = getStoreLattice("default", "channelBinding").store;
279
+ setBindingRegistry(bindingStore);
280
+ ```
281
+
282
+ #### 3. Implement ChannelAdapter (in gateway)
283
+
284
+ ```typescript
285
+ import type { ChannelAdapter, InboundMessage, OutboundMessage, ReplyTarget } from "@axiom-lattice/protocols";
286
+ import { z } from "zod";
287
+
288
+ const slackConfigSchema = z.object({
289
+ botToken: z.string(),
290
+ signingSecret: z.string(),
291
+ });
292
+
293
+ export const slackAdapter: ChannelAdapter = {
294
+ channel: "slack",
295
+ configSchema: slackConfigSchema,
296
+
297
+ async receive(rawPayload, installation): Promise<InboundMessage | null> {
298
+ // 1. Parse Slack-specific event
299
+ // 2. Return normalized InboundMessage or null to ignore
300
+ return {
301
+ channel: "slack",
302
+ channelInstallationId: installation.id,
303
+ tenantId: installation.tenantId,
304
+ sender: { id: userId, displayName: userName },
305
+ content: { text: messageText },
306
+ replyTarget: {
307
+ adapterChannel: "slack",
308
+ channelInstallationId: installation.id,
309
+ rawTarget: { channelId, threadTs },
310
+ },
311
+ };
312
+ },
313
+
314
+ async sendReply(replyTarget, message, installation): Promise<void> {
315
+ // Use Slack API to send reply
316
+ // replyTarget.rawTarget contains channel-specific context
317
+ },
318
+ };
319
+ ```
320
+
321
+ #### 4. Register routes and adapter (in gateway)
322
+
323
+ ```typescript
324
+ // packages/gateway/src/channels/registry.ts
325
+ adapterRegistry.register(slackAdapter);
326
+
327
+ // packages/gateway/src/channels/routes.ts
328
+ app.post("/api/channels/slack/installations/:id/events", async (req, reply) => {
329
+ const installation = await installationStore.getInstallationById(req.params.id);
330
+ const message = await slackAdapter.receive(req.body, installation);
331
+ if (message) await messageRouter.dispatch(message);
332
+ });
333
+ ```
334
+
335
+ ### Binding resolution flow
336
+
337
+ When `MessageRouter.dispatch()` receives an `InboundMessage`:
338
+
339
+ 1. Calls `BindingRegistry.resolve({ channel, senderId, channelInstallationId, tenantId })`
340
+ 2. If **no binding found**:
341
+ - If `installation.rejectWhenNoBinding` → throw `BindingNotFoundError`
342
+ - If `installation.fallbackAgentId` → create temporary fallback binding
343
+ 3. If **binding disabled** → reject
344
+ 4. **Thread resolution** (based on `binding.threadMode`):
345
+ - `"fixed"` → reuse `binding.threadId`
346
+ - `"per_conversation"` → always create new thread
347
+ 5. Execute agent via `agent.addMessage()`
348
+
349
+ ### Agent-side binding management
350
+
351
+ Agents can manage bindings dynamically via the `manage_binding` tool:
352
+
353
+ ```typescript
354
+ // Agent can call this tool to:
355
+ // - list_installations: List available channel installations
356
+ // - create: Bind a sender to an agent (channel, senderId, agentId)
357
+ // - update: Change agent or threadMode for a binding
358
+ // - delete: Remove a binding
359
+ // - list: List all bindings with optional filters
360
+ ```
361
+
362
+ ### Key types (from @axiom-lattice/protocols)
363
+
364
+ ```typescript
365
+ interface ChannelAdapter<TConfig = unknown> {
366
+ readonly channel: string;
367
+ readonly configSchema: z.ZodSchema<TConfig>;
368
+ receive(rawPayload: unknown, installation: ChannelInstallation): Promise<InboundMessage | null>;
369
+ sendReply(replyTarget: ReplyTarget, message: OutboundMessage, installation: ChannelInstallation): Promise<void>;
370
+ }
371
+
372
+ interface Binding {
373
+ id: string;
374
+ channel: string;
375
+ channelInstallationId: string;
376
+ tenantId: string;
377
+ senderId: string;
378
+ agentId: string;
379
+ threadMode: "fixed" | "per_conversation";
380
+ enabled: boolean;
381
+ }
382
+ ```
383
+
384
+ ### Reference implementation
385
+
386
+ See `packages/gateway/src/channels/lark/` for a complete production channel adapter including:
387
+ - Webhook verification and decryption
388
+ - Event parsing and normalization
389
+ - Thread mapping (`user` / `group` / `hybrid` modes)
390
+ - Reply delivery via official SDK