apcore-js 0.2.0 → 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 CHANGED
@@ -5,6 +5,40 @@ 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
+
8
42
  ## [0.2.0] - 2026-02-20
9
43
 
10
44
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apcore-js",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "AI-Perceivable Core — schema-driven module development framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/executor.ts CHANGED
@@ -180,6 +180,96 @@ export class Executor {
180
180
  return this.call(moduleId, inputs, context);
181
181
  }
182
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
+
183
273
  private _createContext(moduleId: string, context?: Context | null): Context {
184
274
  if (context == null) {
185
275
  return Context.create(this).child(moduleId);
@@ -265,10 +355,7 @@ export class Executor {
265
355
 
266
356
  let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
267
357
 
268
- const outputSchema = mod['outputSchema'] as TSchema | undefined;
269
- if (outputSchema != null) {
270
- this._validateSchema(outputSchema, output, 'Output');
271
- }
358
+ this._validateOutput(mod, output);
272
359
 
273
360
  output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
274
361
  return output;
@@ -283,6 +370,13 @@ export class Executor {
283
370
  }
284
371
  }
285
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
+
286
380
  validate(moduleId: string, inputs: Record<string, unknown>): ValidationResult {
287
381
  const module = this._registry.get(moduleId);
288
382
  if (module === null) {
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 {
@@ -11,6 +11,7 @@ const ANNOTATION_FIELDS: ReadonlyArray<keyof ModuleAnnotations> = [
11
11
  'idempotent',
12
12
  'requiresApproval',
13
13
  'openWorld',
14
+ 'streaming',
14
15
  ];
15
16
 
16
17
  export function mergeAnnotations(
@@ -37,6 +37,7 @@ function createModule(
37
37
  idempotent: true,
38
38
  requiresApproval: false,
39
39
  openWorld: false,
40
+ streaming: false,
40
41
  },
41
42
  examples: [
42
43
  {
@@ -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);
@@ -55,6 +55,7 @@ describe('SchemaExporter', () => {
55
55
  idempotent: true,
56
56
  requiresApproval: false,
57
57
  openWorld: false,
58
+ streaming: false,
58
59
  };
59
60
  const result = exporter.exportMcp(sd, annotations, 'MyTool');
60
61
  expect(result['name']).toBe('MyTool');
@@ -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
+ });