@mxf-dev/core 3.1.0 → 3.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.
Files changed (36) hide show
  1. package/dist/config/MeilisearchIngressLimits.d.ts +57 -0
  2. package/dist/config/MeilisearchIngressLimits.d.ts.map +1 -0
  3. package/dist/config/MeilisearchIngressLimits.js +57 -0
  4. package/dist/config/MeilisearchIngressLimits.js.map +1 -0
  5. package/dist/events/EventNames.d.ts +1 -0
  6. package/dist/events/EventNames.d.ts.map +1 -1
  7. package/dist/events/event-definitions/MeilisearchEvents.d.ts +3 -1
  8. package/dist/events/event-definitions/MeilisearchEvents.d.ts.map +1 -1
  9. package/dist/events/event-definitions/MeilisearchEvents.js +4 -0
  10. package/dist/events/event-definitions/MeilisearchEvents.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/models/task.d.ts.map +1 -1
  16. package/dist/models/task.js.map +1 -1
  17. package/dist/schemas/EventPayloadSchema.d.ts +1 -0
  18. package/dist/schemas/EventPayloadSchema.d.ts.map +1 -1
  19. package/dist/schemas/EventPayloadSchema.js +3 -0
  20. package/dist/schemas/EventPayloadSchema.js.map +1 -1
  21. package/dist/services/MxfMeilisearchService.d.ts +19 -3
  22. package/dist/services/MxfMeilisearchService.d.ts.map +1 -1
  23. package/dist/services/MxfMeilisearchService.js +46 -34
  24. package/dist/services/MxfMeilisearchService.js.map +1 -1
  25. package/dist/types/TaskTypes.d.ts +29 -0
  26. package/dist/types/TaskTypes.d.ts.map +1 -1
  27. package/dist/types/TaskTypes.js +41 -1
  28. package/dist/types/TaskTypes.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/config/MeilisearchIngressLimits.ts +63 -0
  31. package/src/events/event-definitions/MeilisearchEvents.ts +7 -1
  32. package/src/index.ts +7 -0
  33. package/src/models/task.ts +5 -1
  34. package/src/schemas/EventPayloadSchema.ts +4 -0
  35. package/src/services/MxfMeilisearchService.ts +55 -43
  36. package/src/types/TaskTypes.ts +69 -2
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Copyright 2024 Brad Anderson
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @author Brad Anderson <BradA1878@pm.me>
17
+ * @repository https://github.com/BradA1878/model-exchange-framework
18
+ * @documentation https://mxf-dev.github.io/mxf/
19
+ */
20
+
21
+ /**
22
+ * Size limits on the conversation search index's socket ingress.
23
+ *
24
+ * The server enforces these in MeilisearchIngressPolicy on every
25
+ * `meilisearch:index:request` and `meilisearch:backfill:request`. The SDK
26
+ * builds its memory-load backfill batches from the same numbers, so a batch
27
+ * it sends is one the server accepts. Before this module existed the SDK
28
+ * batched by count alone while the server rejected by bytes, and an agent
29
+ * whose stored history was dense could never load it again: the same batch
30
+ * was refused on every connect.
31
+ */
32
+
33
+ /**
34
+ * Largest single message content the index accepts, in UTF-8 bytes. A
35
+ * larger message is not indexed: the live path drops it after the server
36
+ * refuses it, and the backfill skips it without sending.
37
+ */
38
+ export const MAX_MEILISEARCH_MESSAGE_BYTES = 64 * 1024;
39
+
40
+ /** Most messages one backfill request may carry. */
41
+ export const MAX_MEILISEARCH_BACKFILL_MESSAGES = 50;
42
+
43
+ /**
44
+ * Most message content one backfill request may carry, as the sum of each
45
+ * message's UTF-8 bytes.
46
+ */
47
+ export const MAX_MEILISEARCH_BACKFILL_CONTENT_BYTES = 512 * 1024;
48
+
49
+ /**
50
+ * Most bytes one backfill request event may occupy once serialized for the
51
+ * socket, envelope included. JSON escaping makes a message's wire size larger
52
+ * than its content bytes (a newline or quote is two bytes on the wire), so
53
+ * the content limit alone does not bound the frame. Engine.IO closes a socket
54
+ * that sends a frame above the server's message limit
55
+ * (MXF_SOCKET_MAX_HTTP_BUFFER_BYTES, default 1 MiB), which would put the
56
+ * agent in a reconnect loop instead of a policy rejection; the server refuses
57
+ * to boot with a message limit this budget does not fit under.
58
+ */
59
+ export const MAX_MEILISEARCH_BACKFILL_WIRE_BYTES = 768 * 1024;
60
+
61
+ /** UTF-8 byte length of message content — the measure every limit above uses. */
62
+ export const meilisearchContentBytes = (content: string): number =>
63
+ Buffer.byteLength(content, 'utf8');
@@ -23,7 +23,7 @@
23
23
  * Events for server-side indexing with embeddings
24
24
  */
25
25
 
26
- import { BaseEventPayload } from '../../schemas/EventPayloadSchema.js';
26
+ import { BaseEventPayload, MeilisearchBackfillEventData } from '../../schemas/EventPayloadSchema.js';
27
27
 
28
28
  /**
29
29
  * Meilisearch event names
@@ -39,6 +39,11 @@ export const MeilisearchEvents = {
39
39
  BACKFILL_COMPLETE: 'meilisearch:backfill:complete',
40
40
  BACKFILL_PARTIAL: 'meilisearch:backfill:partial',
41
41
  BACKFILL_ERROR: 'meilisearch:backfill:error',
42
+
43
+ // Report from the SDK to the server: its memory-load backfill has settled
44
+ // (every batch answered or abandoned). Carries the final counts so the
45
+ // server can mark the agent ready for search tools and log the outcome.
46
+ BACKFILL_SETTLED: 'meilisearch:backfill:settled',
42
47
  } as const;
43
48
 
44
49
  /**
@@ -52,4 +57,5 @@ export interface MeilisearchPayloads {
52
57
  'meilisearch:backfill:complete': BaseEventPayload<any>;
53
58
  'meilisearch:backfill:partial': BaseEventPayload<any>;
54
59
  'meilisearch:backfill:error': BaseEventPayload<any>;
60
+ 'meilisearch:backfill:settled': BaseEventPayload<MeilisearchBackfillEventData>;
55
61
  }
package/src/index.ts CHANGED
@@ -30,3 +30,10 @@ export {
30
30
  buildServerUrl,
31
31
  } from './config/ServerConfig.js';
32
32
  export type { ServerConfig } from './config/ServerConfig.js';
33
+ export {
34
+ MAX_MEILISEARCH_MESSAGE_BYTES,
35
+ MAX_MEILISEARCH_BACKFILL_MESSAGES,
36
+ MAX_MEILISEARCH_BACKFILL_CONTENT_BYTES,
37
+ MAX_MEILISEARCH_BACKFILL_WIRE_BYTES,
38
+ meilisearchContentBytes,
39
+ } from './config/MeilisearchIngressLimits.js';
@@ -90,7 +90,11 @@ export interface ITask extends Document {
90
90
  dependsOn?: string[];
91
91
  blockedBy?: string[];
92
92
 
93
- // Results and outcomes - MongoDB version with Date objects
93
+ // Results and outcomes - MongoDB version with Date objects. `output` is
94
+ // whatever the caller passed to completeTask() or the REST completion
95
+ // route, or a TaskCompletionOutput (see types/TaskTypes.ts) when an agent
96
+ // completed the task by calling task_complete. There is no `result.summary`
97
+ // - use getTaskCompletionOutput() to read the summary safely.
94
98
  result?: {
95
99
  success?: boolean;
96
100
  output?: any;
@@ -3226,6 +3226,7 @@ export interface MeilisearchBackfillEventData {
3226
3226
  totalDocuments: number; // Total documents to backfill
3227
3227
  indexedDocuments: number; // Number of documents successfully indexed
3228
3228
  failedDocuments: number; // Number of documents that failed to index
3229
+ skippedDocuments?: number; // Documents the SDK never sent because they exceed the per-message limit
3229
3230
  duration: number; // Total time taken in milliseconds
3230
3231
  success: boolean; // Whether backfill completed successfully
3231
3232
  source: 'mongodb' | 'memory' | 'other'; // Source of backfilled data
@@ -3302,6 +3303,9 @@ export function createMeilisearchBackfillEventPayload(
3302
3303
  validator.assertIsNumber(data.totalDocuments, 'totalDocuments must be a number');
3303
3304
  validator.assertIsNumber(data.indexedDocuments, 'indexedDocuments must be a number');
3304
3305
  validator.assertIsNumber(data.failedDocuments, 'failedDocuments must be a number');
3306
+ if (data.skippedDocuments !== undefined) {
3307
+ validator.assertIsNumber(data.skippedDocuments, 'skippedDocuments must be a number');
3308
+ }
3305
3309
  validator.assertIsNumber(data.duration, 'duration must be a number');
3306
3310
  validator.assertIsBoolean(data.success);
3307
3311
  validator.assertIsNonEmptyString(data.source, 'source');
@@ -112,6 +112,11 @@ export interface SearchResult<T> {
112
112
  */
113
113
  export type EmbeddingGenerator = (text: string, options?: { model?: string; dimensions?: number }) => Promise<number[]>;
114
114
 
115
+ /** The part of a Meilisearch enqueued-task promise the index methods use. */
116
+ interface IndexTaskPromise {
117
+ waitTask: () => Promise<{ uid: number; status: string; error: { message?: string } | null }>;
118
+ }
119
+
115
120
  /**
116
121
  * Meilisearch Service Configuration
117
122
  */
@@ -364,8 +369,13 @@ export class MxfMeilisearchService {
364
369
  }
365
370
 
366
371
  /**
367
- * Generate embedding for text using provided embedding generator
368
- * Respects SYSTEMLLM_PROVIDER configuration from server
372
+ * Embed text with the generator the server installed.
373
+ *
374
+ * Returns undefined only when embeddings are off or no generator is
375
+ * installed (keyword-only mode). A generator failure is thrown with the
376
+ * provider's reason: a document indexed without the vector its caller
377
+ * expects would still be counted as indexed, and semantic searches would
378
+ * quietly miss it, so the failure has to reach the caller.
369
379
  */
370
380
  private async generateEmbedding(text: string): Promise<number[] | undefined> {
371
381
  if (!this.config.enableEmbeddings || !this.embeddingGenerator) {
@@ -373,26 +383,42 @@ export class MxfMeilisearchService {
373
383
  }
374
384
 
375
385
  try {
376
- // Call the embedding generator function (provided by server)
377
- const embedding = await this.embeddingGenerator(text, {
386
+ return await this.embeddingGenerator(text, {
378
387
  model: this.config.embeddingModel,
379
388
  dimensions: this.config.embeddingDimensions
380
389
  });
381
-
382
- return embedding;
383
390
  } catch (error) {
384
- //this.logger.error(`Embedding generation failed:`, error);
385
- return undefined;
391
+ const reason = error instanceof Error ? error.message : String(error);
392
+ throw new Error(`Embedding generation failed (${this.config.embeddingModel}): ${reason}`);
386
393
  }
387
394
  }
388
395
 
389
396
  /**
390
- * Index a conversation message
397
+ * Wait for an indexing task and throw when Meilisearch did not complete
398
+ * it. waitTask() resolves for a task that ended `failed` or `canceled` —
399
+ * the outcome is on the task, not in the promise.
400
+ */
401
+ private async awaitIndexTask(taskPromise: IndexTaskPromise, what: string): Promise<void> {
402
+ const task = await taskPromise.waitTask();
403
+ if (task.status !== 'succeeded') {
404
+ throw new Error(
405
+ `Meilisearch ${what} task ${task.uid} ${task.status}: ${task.error?.message ?? 'no error detail'}`
406
+ );
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Index a conversation message.
412
+ *
413
+ * Throws when the embedding cannot be generated, the document cannot be
414
+ * enqueued, or Meilisearch fails the task. The caller decides what a
415
+ * missing document means: the server reports it to the SDK, and the SDK
416
+ * counts it against the backfill or drops it from the live index queue.
391
417
  */
392
418
  public async indexConversation(message: ConversationMessage): Promise<void> {
393
419
  try {
394
420
  const embedding = await this.generateEmbedding(message.content);
395
-
421
+
396
422
  // Build document with proper _vectors format for Meilisearch
397
423
  const document: ConversationDocument = {
398
424
  id: message.id,
@@ -407,14 +433,11 @@ export class MxfMeilisearchService {
407
433
  };
408
434
 
409
435
  const index = this.client.index(MeilisearchIndex.CONVERSATIONS);
410
- const taskPromise = index.addDocuments([document]);
411
-
412
- // Wait for indexing task to complete so documents are immediately searchable
413
- await taskPromise.waitTask();
414
-
436
+ // Wait for the task so the document is searchable when this resolves.
437
+ await this.awaitIndexTask(index.addDocuments([document]), `conversation ${message.id}`);
415
438
  } catch (error) {
416
- this.logger.error('Failed to index conversation', error);
417
- // Don't throw - indexing failures shouldn't break the main flow
439
+ this.logger.error(`Failed to index conversation ${message.id}`, error);
440
+ throw error;
418
441
  }
419
442
  }
420
443
 
@@ -444,13 +467,10 @@ export class MxfMeilisearchService {
444
467
  };
445
468
 
446
469
  const index = this.client.index(MeilisearchIndex.ACTIONS);
447
- const taskPromise = index.addDocuments([document]);
448
-
449
- // Wait for indexing task to complete
450
- await taskPromise.waitTask();
451
-
470
+ await this.awaitIndexTask(index.addDocuments([document]), `action ${action.id}`);
452
471
  } catch (error) {
453
- this.logger.error('Failed to index action', error);
472
+ this.logger.error(`Failed to index action ${action.id}`, error);
473
+ throw error;
454
474
  }
455
475
  }
456
476
 
@@ -480,13 +500,10 @@ export class MxfMeilisearchService {
480
500
  };
481
501
 
482
502
  const index = this.client.index(MeilisearchIndex.PATTERNS);
483
- const taskPromise = index.addDocuments([document]);
484
-
485
- // Wait for indexing task to complete
486
- await taskPromise.waitTask();
487
-
503
+ await this.awaitIndexTask(index.addDocuments([document]), `pattern ${pattern.patternId}`);
488
504
  } catch (error) {
489
- this.logger.error('Failed to index pattern', error);
505
+ this.logger.error(`Failed to index pattern ${pattern.patternId}`, error);
506
+ throw error;
490
507
  }
491
508
  }
492
509
 
@@ -535,21 +552,16 @@ export class MxfMeilisearchService {
535
552
  searchParams.attributesToHighlight = params.attributesToHighlight;
536
553
  }
537
554
 
538
- // Enable hybrid search if embeddings are available, generator is configured, and ratio is set
539
- // For user-provided embeddings, we must generate and pass the query embedding
555
+ // Hybrid search needs the query embedded by the same generator that
556
+ // embedded the documents. A generator failure is thrown (through the
557
+ // catch below) rather than quietly answering a semantic request with
558
+ // keyword-only results.
540
559
  if (this.config.enableEmbeddings && this.embeddingGenerator && params.hybridRatio !== undefined) {
541
- // Generate embedding for the search query
542
- const queryEmbedding = await this.generateEmbedding(params.query);
543
-
544
- if (queryEmbedding) {
545
- searchParams.hybrid = {
546
- semanticRatio: params.hybridRatio,
547
- embedder: 'default'
548
- };
549
- // Provide the query embedding for user-provided embedder configuration
550
- searchParams.vector = queryEmbedding;
551
- }
552
- // If embedding generation fails, fall back to keyword-only search
560
+ searchParams.hybrid = {
561
+ semanticRatio: params.hybridRatio,
562
+ embedder: 'default'
563
+ };
564
+ searchParams.vector = await this.generateEmbedding(params.query);
553
565
  }
554
566
 
555
567
  const result = await index.search<T>(params.query, searchParams);
@@ -109,7 +109,11 @@ export interface ChannelTask {
109
109
  dependsOn?: string[]; // Task IDs this task depends on
110
110
  blockedBy?: string[]; // Task IDs that block this task
111
111
 
112
- // Results and outcomes
112
+ // Results and outcomes. `output` is whatever the caller passed to
113
+ // completeTask() or the REST completion route, or a TaskCompletionOutput
114
+ // when an agent completed the task by calling task_complete. There is no
115
+ // `result.summary` — use getTaskCompletionOutput() to read the summary
116
+ // safely regardless of which completion path produced this task.
113
117
  result?: {
114
118
  success?: boolean;
115
119
  output?: any;
@@ -117,10 +121,73 @@ export interface ChannelTask {
117
121
  completedAt?: number;
118
122
  completedBy?: string;
119
123
  };
120
-
124
+
121
125
  assignmentStrategy: AssignmentStrategy;
122
126
  }
123
127
 
128
+ /**
129
+ * What `task_complete` stores in `ChannelTask.result.output`.
130
+ *
131
+ * This is the shape TaskService.handleTaskCompletion writes when an agent
132
+ * completes a task through the task_complete tool. It is not the only shape
133
+ * `result.output` can hold: a caller can also set `result.output` directly
134
+ * through completeTask() or the REST completion route, so this type is a
135
+ * guard to check against, not a blanket narrowing of `output`.
136
+ */
137
+ export interface TaskCompletionOutput {
138
+ agentId: string;
139
+ summary: string;
140
+ details?: Record<string, unknown>;
141
+ nextSteps?: string;
142
+ reportedSuccess: boolean;
143
+ requestId: string;
144
+ }
145
+
146
+ /**
147
+ * Checks whether a value matches the TaskCompletionOutput shape.
148
+ * Used to read `result.output` from a task without assuming which
149
+ * completion path (task_complete vs. completeTask()/REST) produced it.
150
+ */
151
+ export function isTaskCompletionOutput(value: unknown): value is TaskCompletionOutput {
152
+ if (typeof value !== 'object' || value === null) {
153
+ return false;
154
+ }
155
+ const candidate = value as Record<string, unknown>;
156
+ if (typeof candidate.agentId !== 'string' || candidate.agentId.length === 0) {
157
+ return false;
158
+ }
159
+ if (typeof candidate.summary !== 'string') {
160
+ return false;
161
+ }
162
+ if (typeof candidate.reportedSuccess !== 'boolean') {
163
+ return false;
164
+ }
165
+ if (typeof candidate.requestId !== 'string' || candidate.requestId.length === 0) {
166
+ return false;
167
+ }
168
+ if (candidate.details !== undefined) {
169
+ if (typeof candidate.details !== 'object' || candidate.details === null || Array.isArray(candidate.details)) {
170
+ return false;
171
+ }
172
+ }
173
+ if (candidate.nextSteps !== undefined && typeof candidate.nextSteps !== 'string') {
174
+ return false;
175
+ }
176
+ return true;
177
+ }
178
+
179
+ /**
180
+ * Reads `task.result.output` as a TaskCompletionOutput, or returns undefined
181
+ * when the task has no result, no output, or an output that does not match
182
+ * (e.g. a value a caller passed directly to completeTask()/REST).
183
+ */
184
+ export function getTaskCompletionOutput(
185
+ task: Pick<ChannelTask, 'result'> | null | undefined
186
+ ): TaskCompletionOutput | undefined {
187
+ const output = task?.result?.output;
188
+ return isTaskCompletionOutput(output) ? output : undefined;
189
+ }
190
+
124
191
  /**
125
192
  * Task creation request
126
193
  */