apcore-js 0.1.2 → 0.3.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/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/planning/overview.md +1 -1
- package/src/errors.ts +55 -0
- package/src/executor.ts +110 -4
- package/src/index.ts +5 -1
- package/src/module.ts +2 -0
- package/src/registry/registry.ts +31 -9
- package/src/schema/annotations.ts +1 -0
- package/tests/integration/test-e2e-flow.test.ts +4 -4
- package/tests/registry/test-schema-export.test.ts +1 -0
- package/tests/schema/test-annotations.test.ts +2 -0
- package/tests/schema/test-exporter.test.ts +1 -0
- package/tests/test-executor-stream.test.ts +208 -0
- package/tests/test-executor.test.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,65 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-02-20
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Use shallow merge for `stream()` accumulation instead of last-chunk.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Add `Executor.stream()` async generator and `ModuleAnnotations.streaming` for streaming support in the core execution pipeline.
|
|
15
|
+
|
|
16
|
+
### Co-Authors
|
|
17
|
+
- Claude Opus 4.6 <noreply@anthropic.com>
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Error classes and constants**
|
|
22
|
+
- `ModuleExecuteError` — New error class for module execution failures
|
|
23
|
+
- `InternalError` — New error class for general internal errors
|
|
24
|
+
- `ErrorCodes` — Frozen object with all 26 error code strings for consistent error code usage
|
|
25
|
+
- `ErrorCode` — Type definition for all error codes
|
|
26
|
+
- **Registry constants**
|
|
27
|
+
- `REGISTRY_EVENTS` — Frozen object with standard event names (`register`, `unregister`)
|
|
28
|
+
- `MODULE_ID_PATTERN` — Regex pattern enforcing lowercase/digits/underscores/dots for module IDs (no hyphens allowed to ensure bijective MCP tool name normalization)
|
|
29
|
+
- **Executor methods**
|
|
30
|
+
- `Executor.callAsync()` — Alias for `call()` for compatibility with MCP bridge packages
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **Module ID validation** — Registry now validates module IDs against `MODULE_ID_PATTERN` on registration, rejecting IDs with hyphens or invalid characters
|
|
35
|
+
- **Event handling** — Registry event validation now uses `REGISTRY_EVENTS` constants instead of hardcoded strings
|
|
36
|
+
- **Test updates** — Updated tests to use underscore-separated module IDs instead of hyphens (e.g., `math.add_ten` instead of `math.addTen`, `ctx_test` instead of `ctx-test`)
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **String literals in Registry** — Replaced hardcoded `'register'` and `'unregister'` strings with `REGISTRY_EVENTS.REGISTER` and `REGISTRY_EVENTS.UNREGISTER` constants in event triggers for consistency
|
|
41
|
+
|
|
42
|
+
## [0.2.0] - 2026-02-20
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **Error classes and constants**
|
|
47
|
+
- `ModuleExecuteError` — New error class for module execution failures
|
|
48
|
+
- `InternalError` — New error class for general internal errors
|
|
49
|
+
- `ErrorCodes` — Frozen object with all 26 error code strings for consistent error code usage
|
|
50
|
+
- `ErrorCode` — Type definition for all error codes
|
|
51
|
+
- **Registry constants**
|
|
52
|
+
- `REGISTRY_EVENTS` — Frozen object with standard event names (`register`, `unregister`)
|
|
53
|
+
- `MODULE_ID_PATTERN` — Regex pattern enforcing lowercase/digits/underscores/dots for module IDs (no hyphens allowed to ensure bijective MCP tool name normalization)
|
|
54
|
+
- **Executor methods**
|
|
55
|
+
- `Executor.callAsync()` — Alias for `call()` for compatibility with MCP bridge packages
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **Module ID validation** — Registry now validates module IDs against `MODULE_ID_PATTERN` on registration, rejecting IDs with hyphens or invalid characters
|
|
60
|
+
- **Event handling** — Registry event validation now uses `REGISTRY_EVENTS` constants instead of hardcoded strings
|
|
61
|
+
- **Test updates** — Updated tests to use underscore-separated module IDs instead of hyphens (e.g., `math.add_ten` instead of `math.addTen`, `ctx_test` instead of `ctx-test`)
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **String literals in Registry** — Replaced hardcoded `'register'` and `'unregister'` strings with `REGISTRY_EVENTS.REGISTER` and `REGISTRY_EVENTS.UNREGISTER` constants in event triggers for consistency
|
|
66
|
+
|
|
8
67
|
## [0.1.2] - 2026-02-18
|
|
9
68
|
|
|
10
69
|
### Fixed
|
package/package.json
CHANGED
package/planning/overview.md
CHANGED
package/src/errors.ts
CHANGED
|
@@ -367,3 +367,58 @@ export class ModuleLoadError extends ModuleError {
|
|
|
367
367
|
this.name = 'ModuleLoadError';
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
|
+
|
|
371
|
+
export class ModuleExecuteError extends ModuleError {
|
|
372
|
+
constructor(moduleId: string, reason: string, options?: { cause?: Error; traceId?: string }) {
|
|
373
|
+
super(
|
|
374
|
+
'MODULE_EXECUTE_ERROR',
|
|
375
|
+
`Failed to execute module '${moduleId}': ${reason}`,
|
|
376
|
+
{ moduleId, reason },
|
|
377
|
+
options?.cause,
|
|
378
|
+
options?.traceId,
|
|
379
|
+
);
|
|
380
|
+
this.name = 'ModuleExecuteError';
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export class InternalError extends ModuleError {
|
|
385
|
+
constructor(message: string = 'Internal error', options?: { cause?: Error; traceId?: string }) {
|
|
386
|
+
super('GENERAL_INTERNAL_ERROR', message, {}, options?.cause, options?.traceId);
|
|
387
|
+
this.name = 'InternalError';
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* All framework error codes as constants.
|
|
393
|
+
* Use these instead of hardcoding error code strings.
|
|
394
|
+
*/
|
|
395
|
+
export const ErrorCodes = Object.freeze({
|
|
396
|
+
CONFIG_NOT_FOUND: "CONFIG_NOT_FOUND",
|
|
397
|
+
CONFIG_INVALID: "CONFIG_INVALID",
|
|
398
|
+
ACL_RULE_ERROR: "ACL_RULE_ERROR",
|
|
399
|
+
ACL_DENIED: "ACL_DENIED",
|
|
400
|
+
MODULE_NOT_FOUND: "MODULE_NOT_FOUND",
|
|
401
|
+
MODULE_TIMEOUT: "MODULE_TIMEOUT",
|
|
402
|
+
MODULE_LOAD_ERROR: "MODULE_LOAD_ERROR",
|
|
403
|
+
MODULE_EXECUTE_ERROR: "MODULE_EXECUTE_ERROR",
|
|
404
|
+
SCHEMA_VALIDATION_ERROR: "SCHEMA_VALIDATION_ERROR",
|
|
405
|
+
SCHEMA_NOT_FOUND: "SCHEMA_NOT_FOUND",
|
|
406
|
+
SCHEMA_PARSE_ERROR: "SCHEMA_PARSE_ERROR",
|
|
407
|
+
SCHEMA_CIRCULAR_REF: "SCHEMA_CIRCULAR_REF",
|
|
408
|
+
CALL_DEPTH_EXCEEDED: "CALL_DEPTH_EXCEEDED",
|
|
409
|
+
CIRCULAR_CALL: "CIRCULAR_CALL",
|
|
410
|
+
CALL_FREQUENCY_EXCEEDED: "CALL_FREQUENCY_EXCEEDED",
|
|
411
|
+
GENERAL_INVALID_INPUT: "GENERAL_INVALID_INPUT",
|
|
412
|
+
GENERAL_INTERNAL_ERROR: "GENERAL_INTERNAL_ERROR",
|
|
413
|
+
FUNC_MISSING_TYPE_HINT: "FUNC_MISSING_TYPE_HINT",
|
|
414
|
+
FUNC_MISSING_RETURN_TYPE: "FUNC_MISSING_RETURN_TYPE",
|
|
415
|
+
BINDING_INVALID_TARGET: "BINDING_INVALID_TARGET",
|
|
416
|
+
BINDING_MODULE_NOT_FOUND: "BINDING_MODULE_NOT_FOUND",
|
|
417
|
+
BINDING_CALLABLE_NOT_FOUND: "BINDING_CALLABLE_NOT_FOUND",
|
|
418
|
+
BINDING_NOT_CALLABLE: "BINDING_NOT_CALLABLE",
|
|
419
|
+
BINDING_SCHEMA_MISSING: "BINDING_SCHEMA_MISSING",
|
|
420
|
+
BINDING_FILE_INVALID: "BINDING_FILE_INVALID",
|
|
421
|
+
CIRCULAR_DEPENDENCY: "CIRCULAR_DEPENDENCY",
|
|
422
|
+
} as const);
|
|
423
|
+
|
|
424
|
+
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
package/src/executor.ts
CHANGED
|
@@ -168,6 +168,108 @@ export class Executor {
|
|
|
168
168
|
return this._executeWithMiddleware(mod, moduleId, effectiveInputs, ctx);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Alias for call(). Provided for compatibility with MCP bridge packages
|
|
173
|
+
* that may call callAsync() by convention.
|
|
174
|
+
*/
|
|
175
|
+
async callAsync(
|
|
176
|
+
moduleId: string,
|
|
177
|
+
inputs?: Record<string, unknown> | null,
|
|
178
|
+
context?: Context | null,
|
|
179
|
+
): Promise<Record<string, unknown>> {
|
|
180
|
+
return this.call(moduleId, inputs, context);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Streaming execution pipeline. If the module exposes a stream() async generator,
|
|
185
|
+
* yields each chunk. Otherwise falls back to call() and yields a single chunk.
|
|
186
|
+
*
|
|
187
|
+
* Pipeline: context -> safety -> lookup -> ACL -> validate inputs -> before-middleware
|
|
188
|
+
* -> stream (or fallback to execute) -> validate accumulated output -> after-middleware
|
|
189
|
+
*/
|
|
190
|
+
async *stream(
|
|
191
|
+
moduleId: string,
|
|
192
|
+
inputs?: Record<string, unknown> | null,
|
|
193
|
+
context?: Context | null,
|
|
194
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
195
|
+
let effectiveInputs = inputs ?? {};
|
|
196
|
+
const ctx = this._createContext(moduleId, context);
|
|
197
|
+
this._checkSafety(moduleId, ctx);
|
|
198
|
+
|
|
199
|
+
const mod = this._lookupModule(moduleId);
|
|
200
|
+
this._checkAcl(moduleId, ctx);
|
|
201
|
+
|
|
202
|
+
effectiveInputs = this._validateInputs(mod, effectiveInputs, ctx);
|
|
203
|
+
|
|
204
|
+
yield* this._streamWithMiddleware(mod, moduleId, effectiveInputs, ctx);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async *_streamWithMiddleware(
|
|
208
|
+
mod: Record<string, unknown>,
|
|
209
|
+
moduleId: string,
|
|
210
|
+
inputs: Record<string, unknown>,
|
|
211
|
+
ctx: Context,
|
|
212
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
213
|
+
let effectiveInputs = inputs;
|
|
214
|
+
let executedMiddlewares: Middleware[] = [];
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
try {
|
|
218
|
+
[effectiveInputs, executedMiddlewares] = this._middlewareManager.executeBefore(moduleId, effectiveInputs, ctx);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (e instanceof MiddlewareChainError) {
|
|
221
|
+
executedMiddlewares = e.executedMiddlewares;
|
|
222
|
+
const recovery = this._middlewareManager.executeOnError(
|
|
223
|
+
moduleId, effectiveInputs, e.original, ctx, executedMiddlewares,
|
|
224
|
+
);
|
|
225
|
+
if (recovery !== null) {
|
|
226
|
+
yield recovery;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
executedMiddlewares = [];
|
|
230
|
+
throw e.original;
|
|
231
|
+
}
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const streamFn = mod['stream'] as
|
|
236
|
+
| ((inputs: Record<string, unknown>, context: Context) => AsyncGenerator<Record<string, unknown>>)
|
|
237
|
+
| undefined;
|
|
238
|
+
|
|
239
|
+
if (typeof streamFn === 'function') {
|
|
240
|
+
// Module has a stream() method: iterate and yield each chunk
|
|
241
|
+
let accumulated: Record<string, unknown> = {};
|
|
242
|
+
for await (const chunk of streamFn.call(mod, effectiveInputs, ctx)) {
|
|
243
|
+
accumulated = { ...accumulated, ...chunk };
|
|
244
|
+
yield chunk;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Validate accumulated output against output schema
|
|
248
|
+
this._validateOutput(mod, accumulated);
|
|
249
|
+
|
|
250
|
+
// Run after-middleware on the accumulated result
|
|
251
|
+
this._middlewareManager.executeAfter(moduleId, effectiveInputs, accumulated, ctx);
|
|
252
|
+
} else {
|
|
253
|
+
// Fallback: execute normally and yield single chunk
|
|
254
|
+
let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
|
|
255
|
+
this._validateOutput(mod, output);
|
|
256
|
+
output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
|
|
257
|
+
yield output;
|
|
258
|
+
}
|
|
259
|
+
} catch (exc) {
|
|
260
|
+
if (executedMiddlewares.length > 0) {
|
|
261
|
+
const recovery = this._middlewareManager.executeOnError(
|
|
262
|
+
moduleId, effectiveInputs, exc as Error, ctx, executedMiddlewares,
|
|
263
|
+
);
|
|
264
|
+
if (recovery !== null) {
|
|
265
|
+
yield recovery;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
throw exc;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
171
273
|
private _createContext(moduleId: string, context?: Context | null): Context {
|
|
172
274
|
if (context == null) {
|
|
173
275
|
return Context.create(this).child(moduleId);
|
|
@@ -253,10 +355,7 @@ export class Executor {
|
|
|
253
355
|
|
|
254
356
|
let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
|
|
255
357
|
|
|
256
|
-
|
|
257
|
-
if (outputSchema != null) {
|
|
258
|
-
this._validateSchema(outputSchema, output, 'Output');
|
|
259
|
-
}
|
|
358
|
+
this._validateOutput(mod, output);
|
|
260
359
|
|
|
261
360
|
output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
|
|
262
361
|
return output;
|
|
@@ -271,6 +370,13 @@ export class Executor {
|
|
|
271
370
|
}
|
|
272
371
|
}
|
|
273
372
|
|
|
373
|
+
private _validateOutput(mod: Record<string, unknown>, output: Record<string, unknown>): void {
|
|
374
|
+
const outputSchema = mod['outputSchema'] as TSchema | undefined;
|
|
375
|
+
if (outputSchema != null) {
|
|
376
|
+
this._validateSchema(outputSchema, output, 'Output');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
274
380
|
validate(moduleId: string, inputs: Record<string, unknown>): ValidationResult {
|
|
275
381
|
const module = this._registry.get(moduleId);
|
|
276
382
|
if (module === null) {
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Core
|
|
6
6
|
export { Context, createIdentity } from './context.js';
|
|
7
7
|
export type { Identity } from './context.js';
|
|
8
|
-
export { Registry } from './registry/registry.js';
|
|
8
|
+
export { Registry, REGISTRY_EVENTS, MODULE_ID_PATTERN } from './registry/registry.js';
|
|
9
9
|
export { Executor, redactSensitive, REDACTED_VALUE } from './executor.js';
|
|
10
10
|
|
|
11
11
|
// Module types
|
|
@@ -42,7 +42,11 @@ export {
|
|
|
42
42
|
BindingFileInvalidError,
|
|
43
43
|
CircularDependencyError,
|
|
44
44
|
ModuleLoadError,
|
|
45
|
+
ModuleExecuteError,
|
|
46
|
+
InternalError,
|
|
47
|
+
ErrorCodes,
|
|
45
48
|
} from './errors.js';
|
|
49
|
+
export type { ErrorCode } from './errors.js';
|
|
46
50
|
|
|
47
51
|
// ACL
|
|
48
52
|
export { ACL } from './acl.js';
|
package/src/module.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface ModuleAnnotations {
|
|
|
11
11
|
readonly idempotent: boolean;
|
|
12
12
|
readonly requiresApproval: boolean;
|
|
13
13
|
readonly openWorld: boolean;
|
|
14
|
+
readonly streaming: boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_ANNOTATIONS: ModuleAnnotations = Object.freeze({
|
|
@@ -19,6 +20,7 @@ export const DEFAULT_ANNOTATIONS: ModuleAnnotations = Object.freeze({
|
|
|
19
20
|
idempotent: false,
|
|
20
21
|
requiresApproval: false,
|
|
21
22
|
openWorld: true,
|
|
23
|
+
streaming: false,
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
export interface ModuleExample {
|
package/src/registry/registry.ts
CHANGED
|
@@ -13,6 +13,20 @@ import { scanExtensions, scanMultiRoot } from './scanner.js';
|
|
|
13
13
|
import type { DependencyInfo, ModuleDescriptor } from './types.js';
|
|
14
14
|
import { validateModule } from './validation.js';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Standard registry event names.
|
|
18
|
+
*/
|
|
19
|
+
export const REGISTRY_EVENTS = Object.freeze({
|
|
20
|
+
REGISTER: "register",
|
|
21
|
+
UNREGISTER: "unregister",
|
|
22
|
+
} as const);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Valid module ID pattern. Only lowercase letters, digits, underscores, and dots.
|
|
26
|
+
* Hyphens are prohibited to ensure bijective MCP/OpenAI tool name normalization.
|
|
27
|
+
*/
|
|
28
|
+
export const MODULE_ID_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
29
|
+
|
|
16
30
|
type EventCallback = (moduleId: string, module: unknown) => void;
|
|
17
31
|
|
|
18
32
|
export class Registry {
|
|
@@ -20,8 +34,8 @@ export class Registry {
|
|
|
20
34
|
private _modules: Map<string, unknown> = new Map();
|
|
21
35
|
private _moduleMeta: Map<string, Record<string, unknown>> = new Map();
|
|
22
36
|
private _callbacks: Map<string, EventCallback[]> = new Map([
|
|
23
|
-
[
|
|
24
|
-
[
|
|
37
|
+
[REGISTRY_EVENTS.REGISTER, []],
|
|
38
|
+
[REGISTRY_EVENTS.UNREGISTER, []],
|
|
25
39
|
]);
|
|
26
40
|
private _idMap: Record<string, Record<string, unknown>> = {};
|
|
27
41
|
private _schemaCache: Map<string, Record<string, unknown>> = new Map();
|
|
@@ -187,15 +201,20 @@ export class Registry {
|
|
|
187
201
|
}
|
|
188
202
|
}
|
|
189
203
|
|
|
190
|
-
this._triggerEvent(
|
|
204
|
+
this._triggerEvent(REGISTRY_EVENTS.REGISTER, modId, mod);
|
|
191
205
|
count++;
|
|
192
206
|
}
|
|
193
207
|
return count;
|
|
194
208
|
}
|
|
195
209
|
|
|
196
210
|
register(moduleId: string, module: unknown): void {
|
|
197
|
-
if (!moduleId) {
|
|
198
|
-
throw new InvalidInputError(
|
|
211
|
+
if (!moduleId || typeof moduleId !== "string") {
|
|
212
|
+
throw new InvalidInputError("Module ID must be a non-empty string");
|
|
213
|
+
}
|
|
214
|
+
if (!MODULE_ID_PATTERN.test(moduleId)) {
|
|
215
|
+
throw new InvalidInputError(
|
|
216
|
+
`Invalid module ID: "${moduleId}". Must match pattern: ${MODULE_ID_PATTERN} (lowercase, digits, underscores, dots only; no hyphens)`,
|
|
217
|
+
);
|
|
199
218
|
}
|
|
200
219
|
|
|
201
220
|
if (this._modules.has(moduleId)) {
|
|
@@ -215,7 +234,7 @@ export class Registry {
|
|
|
215
234
|
}
|
|
216
235
|
}
|
|
217
236
|
|
|
218
|
-
this._triggerEvent(
|
|
237
|
+
this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
|
|
219
238
|
}
|
|
220
239
|
|
|
221
240
|
unregister(moduleId: string): boolean {
|
|
@@ -236,7 +255,7 @@ export class Registry {
|
|
|
236
255
|
}
|
|
237
256
|
}
|
|
238
257
|
|
|
239
|
-
this._triggerEvent(
|
|
258
|
+
this._triggerEvent(REGISTRY_EVENTS.UNREGISTER, moduleId, module);
|
|
240
259
|
return true;
|
|
241
260
|
}
|
|
242
261
|
|
|
@@ -311,8 +330,11 @@ export class Registry {
|
|
|
311
330
|
}
|
|
312
331
|
|
|
313
332
|
on(event: string, callback: EventCallback): void {
|
|
314
|
-
|
|
315
|
-
|
|
333
|
+
const validEvents = Object.values(REGISTRY_EVENTS) as string[];
|
|
334
|
+
if (!validEvents.includes(event)) {
|
|
335
|
+
throw new InvalidInputError(
|
|
336
|
+
`Invalid event: ${event}. Must be one of: ${validEvents.map((e) => `'${e}'`).join(', ')}`,
|
|
337
|
+
);
|
|
316
338
|
}
|
|
317
339
|
this._callbacks.get(event)!.push(callback);
|
|
318
340
|
}
|
|
@@ -72,21 +72,21 @@ describe('E2E Flow', () => {
|
|
|
72
72
|
});
|
|
73
73
|
const modB = new FunctionModule({
|
|
74
74
|
execute: (inputs) => ({ value: (inputs['x'] as number) + 10 }),
|
|
75
|
-
moduleId: 'math.
|
|
75
|
+
moduleId: 'math.add_ten',
|
|
76
76
|
inputSchema: Type.Object({ x: Type.Number() }),
|
|
77
77
|
outputSchema: Type.Object({ value: Type.Number() }),
|
|
78
78
|
description: 'Add ten',
|
|
79
79
|
});
|
|
80
80
|
registry.register('math.double', modA);
|
|
81
|
-
registry.register('math.
|
|
81
|
+
registry.register('math.add_ten', modB);
|
|
82
82
|
|
|
83
83
|
const executor = new Executor({ registry });
|
|
84
|
-
const ctx = Context.create(executor, createIdentity('
|
|
84
|
+
const ctx = Context.create(executor, createIdentity('test_user'));
|
|
85
85
|
|
|
86
86
|
const r1 = await executor.call('math.double', { x: 5 }, ctx);
|
|
87
87
|
expect(r1['value']).toBe(10);
|
|
88
88
|
|
|
89
|
-
const r2 = await executor.call('math.
|
|
89
|
+
const r2 = await executor.call('math.add_ten', { x: r1['value'] as number }, ctx);
|
|
90
90
|
expect(r2['value']).toBe(20);
|
|
91
91
|
});
|
|
92
92
|
|
|
@@ -25,6 +25,7 @@ describe('mergeAnnotations', () => {
|
|
|
25
25
|
idempotent: true,
|
|
26
26
|
requiresApproval: false,
|
|
27
27
|
openWorld: false,
|
|
28
|
+
streaming: false,
|
|
28
29
|
};
|
|
29
30
|
const result = mergeAnnotations(null, codeAnnotations);
|
|
30
31
|
expect(result.readonly).toBe(true);
|
|
@@ -39,6 +40,7 @@ describe('mergeAnnotations', () => {
|
|
|
39
40
|
idempotent: false,
|
|
40
41
|
requiresApproval: false,
|
|
41
42
|
openWorld: true,
|
|
43
|
+
streaming: false,
|
|
42
44
|
};
|
|
43
45
|
const yamlAnnotations = { readonly: false, destructive: true };
|
|
44
46
|
const result = mergeAnnotations(yamlAnnotations, codeAnnotations);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { Executor } from '../src/executor.js';
|
|
4
|
+
import { FunctionModule } from '../src/decorator.js';
|
|
5
|
+
import { Registry } from '../src/registry/registry.js';
|
|
6
|
+
import { Middleware } from '../src/middleware/base.js';
|
|
7
|
+
import { ModuleNotFoundError } from '../src/errors.js';
|
|
8
|
+
|
|
9
|
+
function createSimpleModule(id: string): FunctionModule {
|
|
10
|
+
return new FunctionModule({
|
|
11
|
+
execute: (inputs) => ({ greeting: `Hello, ${inputs['name'] ?? 'world'}!` }),
|
|
12
|
+
moduleId: id,
|
|
13
|
+
inputSchema: Type.Object({ name: Type.Optional(Type.String()) }),
|
|
14
|
+
outputSchema: Type.Object({ greeting: Type.String() }),
|
|
15
|
+
description: 'Greet module',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates a module with a stream() async generator that yields chunks.
|
|
21
|
+
*/
|
|
22
|
+
function createStreamingModule(id: string): FunctionModule & { stream: (inputs: Record<string, unknown>) => AsyncGenerator<Record<string, unknown>> } {
|
|
23
|
+
const mod = new FunctionModule({
|
|
24
|
+
execute: (inputs) => ({ greeting: `Hello, ${inputs['name'] ?? 'world'}!` }),
|
|
25
|
+
moduleId: id,
|
|
26
|
+
inputSchema: Type.Object({ name: Type.Optional(Type.String()) }),
|
|
27
|
+
outputSchema: Type.Object({ greeting: Type.String() }),
|
|
28
|
+
description: 'Streaming greet module',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Attach a stream method to the module
|
|
32
|
+
const streamingMod = mod as FunctionModule & { stream: (inputs: Record<string, unknown>) => AsyncGenerator<Record<string, unknown>> };
|
|
33
|
+
streamingMod.stream = async function* (inputs: Record<string, unknown>): AsyncGenerator<Record<string, unknown>> {
|
|
34
|
+
const name = (inputs['name'] as string) ?? 'world';
|
|
35
|
+
yield { greeting: `Hello, ` };
|
|
36
|
+
yield { greeting: `${name}` };
|
|
37
|
+
yield { greeting: `!` };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return streamingMod;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function collectChunks(gen: AsyncGenerator<Record<string, unknown>>): Promise<Record<string, unknown>[]> {
|
|
44
|
+
const chunks: Record<string, unknown>[] = [];
|
|
45
|
+
for await (const chunk of gen) {
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
}
|
|
48
|
+
return chunks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Executor.stream()', () => {
|
|
52
|
+
it('falls back to single chunk when module has no stream()', async () => {
|
|
53
|
+
const registry = new Registry();
|
|
54
|
+
const mod = createSimpleModule('greet');
|
|
55
|
+
registry.register('greet', mod);
|
|
56
|
+
|
|
57
|
+
const executor = new Executor({ registry });
|
|
58
|
+
const chunks = await collectChunks(executor.stream('greet', { name: 'Alice' }));
|
|
59
|
+
|
|
60
|
+
expect(chunks).toHaveLength(1);
|
|
61
|
+
expect(chunks[0]['greeting']).toBe('Hello, Alice!');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('yields multiple chunks from streaming module', async () => {
|
|
65
|
+
const registry = new Registry();
|
|
66
|
+
const mod = createStreamingModule('greet');
|
|
67
|
+
registry.register('greet', mod);
|
|
68
|
+
|
|
69
|
+
const executor = new Executor({ registry });
|
|
70
|
+
const chunks = await collectChunks(executor.stream('greet', { name: 'Bob' }));
|
|
71
|
+
|
|
72
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
73
|
+
expect(chunks[0]['greeting']).toBe('Hello, ');
|
|
74
|
+
expect(chunks[1]['greeting']).toBe('Bob');
|
|
75
|
+
expect(chunks[2]['greeting']).toBe('!');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('throws ModuleNotFoundError for unknown module', async () => {
|
|
79
|
+
const registry = new Registry();
|
|
80
|
+
const executor = new Executor({ registry });
|
|
81
|
+
|
|
82
|
+
const chunks: Record<string, unknown>[] = [];
|
|
83
|
+
await expect(async () => {
|
|
84
|
+
for await (const chunk of executor.stream('nonexistent')) {
|
|
85
|
+
chunks.push(chunk);
|
|
86
|
+
}
|
|
87
|
+
}).rejects.toThrow(ModuleNotFoundError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('runs before-middleware before streaming and after-middleware on accumulated result', async () => {
|
|
91
|
+
const registry = new Registry();
|
|
92
|
+
const mod = createStreamingModule('echo');
|
|
93
|
+
registry.register('echo', mod);
|
|
94
|
+
|
|
95
|
+
const calls: string[] = [];
|
|
96
|
+
class TrackingMiddleware extends Middleware {
|
|
97
|
+
override before() { calls.push('before'); return null; }
|
|
98
|
+
override after() { calls.push('after'); return null; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const executor = new Executor({ registry, middlewares: [new TrackingMiddleware()] });
|
|
102
|
+
const chunks = await collectChunks(executor.stream('echo', { name: 'Test' }));
|
|
103
|
+
|
|
104
|
+
expect(chunks.length).toBeGreaterThan(0);
|
|
105
|
+
expect(calls).toContain('before');
|
|
106
|
+
expect(calls).toContain('after');
|
|
107
|
+
// before must come first
|
|
108
|
+
expect(calls.indexOf('before')).toBeLessThan(calls.indexOf('after'));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('runs before-middleware before fallback and after-middleware on result', async () => {
|
|
112
|
+
const registry = new Registry();
|
|
113
|
+
const mod = createSimpleModule('echo');
|
|
114
|
+
registry.register('echo', mod);
|
|
115
|
+
|
|
116
|
+
const calls: string[] = [];
|
|
117
|
+
class TrackingMiddleware extends Middleware {
|
|
118
|
+
override before() { calls.push('before'); return null; }
|
|
119
|
+
override after() { calls.push('after'); return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const executor = new Executor({ registry, middlewares: [new TrackingMiddleware()] });
|
|
123
|
+
const chunks = await collectChunks(executor.stream('echo', { name: 'Test' }));
|
|
124
|
+
|
|
125
|
+
expect(chunks).toHaveLength(1);
|
|
126
|
+
expect(calls).toEqual(['before', 'after']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('handles middleware error recovery in streaming mode', async () => {
|
|
130
|
+
const registry = new Registry();
|
|
131
|
+
const failMod = new FunctionModule({
|
|
132
|
+
execute: () => { throw new Error('stream-boom'); },
|
|
133
|
+
moduleId: 'fail',
|
|
134
|
+
inputSchema: Type.Object({}),
|
|
135
|
+
outputSchema: Type.Object({}),
|
|
136
|
+
description: 'Failing module',
|
|
137
|
+
});
|
|
138
|
+
registry.register('fail', failMod);
|
|
139
|
+
|
|
140
|
+
class RecoveryMiddleware extends Middleware {
|
|
141
|
+
override onError() { return { recovered: true }; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const executor = new Executor({ registry, middlewares: [new RecoveryMiddleware()] });
|
|
145
|
+
// Non-streaming module fallback with error should recover
|
|
146
|
+
const chunks = await collectChunks(executor.stream('fail'));
|
|
147
|
+
expect(chunks).toHaveLength(1);
|
|
148
|
+
expect(chunks[0]['recovered']).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('accumulates chunks via shallow merge for after-middleware', async () => {
|
|
152
|
+
const registry = new Registry();
|
|
153
|
+
const mod = {
|
|
154
|
+
description: 'multi-key streaming module',
|
|
155
|
+
inputSchema: Type.Object({ prefix: Type.String() }),
|
|
156
|
+
outputSchema: Type.Object({ a: Type.Optional(Type.String()), b: Type.Optional(Type.String()) }),
|
|
157
|
+
execute: async (inputs: Record<string, unknown>) => ({ a: `${inputs['prefix']}_a`, b: `${inputs['prefix']}_b` }),
|
|
158
|
+
async *stream(inputs: Record<string, unknown>) {
|
|
159
|
+
yield { a: `${inputs['prefix']}_a` };
|
|
160
|
+
yield { b: `${inputs['prefix']}_b` };
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
registry.register('multi', mod);
|
|
164
|
+
|
|
165
|
+
let afterOutput: Record<string, unknown> | null = null;
|
|
166
|
+
const executor = new Executor({ registry });
|
|
167
|
+
executor.useAfter((_mid, _inputs, output) => {
|
|
168
|
+
afterOutput = { ...output };
|
|
169
|
+
return null;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const chunks: Record<string, unknown>[] = [];
|
|
173
|
+
for await (const chunk of executor.stream('multi', { prefix: 'test' })) {
|
|
174
|
+
chunks.push(chunk);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
expect(chunks).toHaveLength(2);
|
|
178
|
+
expect(chunks[0]).toEqual({ a: 'test_a' });
|
|
179
|
+
expect(chunks[1]).toEqual({ b: 'test_b' });
|
|
180
|
+
// After-middleware should receive the MERGED result
|
|
181
|
+
expect(afterOutput).toEqual({ a: 'test_a', b: 'test_b' });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('validates output schema on accumulated streaming result', async () => {
|
|
185
|
+
const registry = new Registry();
|
|
186
|
+
const mod = new FunctionModule({
|
|
187
|
+
execute: () => ({ greeting: 'fallback' }),
|
|
188
|
+
moduleId: 'validated',
|
|
189
|
+
inputSchema: Type.Object({}),
|
|
190
|
+
outputSchema: Type.Object({ greeting: Type.String() }),
|
|
191
|
+
description: 'Validated module',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Attach a stream that produces valid accumulated output
|
|
195
|
+
const streamingMod = mod as FunctionModule & { stream: (inputs: Record<string, unknown>) => AsyncGenerator<Record<string, unknown>> };
|
|
196
|
+
streamingMod.stream = async function* (): AsyncGenerator<Record<string, unknown>> {
|
|
197
|
+
yield { greeting: 'chunk1' };
|
|
198
|
+
yield { greeting: 'chunk2' };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
registry.register('validated', streamingMod);
|
|
202
|
+
|
|
203
|
+
const executor = new Executor({ registry });
|
|
204
|
+
// The last chunk is used as the accumulated output for validation
|
|
205
|
+
const chunks = await collectChunks(executor.stream('validated'));
|
|
206
|
+
expect(chunks).toHaveLength(2);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -221,16 +221,16 @@ describe('Executor', () => {
|
|
|
221
221
|
let capturedCtx: Context | null = null;
|
|
222
222
|
const mod = new FunctionModule({
|
|
223
223
|
execute: (_inputs, ctx) => { capturedCtx = ctx; return { ok: true }; },
|
|
224
|
-
moduleId: '
|
|
224
|
+
moduleId: 'ctx_test',
|
|
225
225
|
inputSchema: Type.Object({}),
|
|
226
226
|
outputSchema: Type.Object({ ok: Type.Boolean() }),
|
|
227
227
|
description: 'Context capture',
|
|
228
228
|
});
|
|
229
|
-
registry.register('
|
|
229
|
+
registry.register('ctx_test', mod);
|
|
230
230
|
|
|
231
231
|
const executor = new Executor({ registry });
|
|
232
232
|
const ctx = Context.create(executor, createIdentity('user1'));
|
|
233
|
-
await executor.call('
|
|
233
|
+
await executor.call('ctx_test', {}, ctx);
|
|
234
234
|
|
|
235
235
|
expect(capturedCtx).not.toBeNull();
|
|
236
236
|
expect(capturedCtx!.traceId).toBe(ctx.traceId);
|