@agentxjs/devtools 1.9.1-dev

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.
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Devtools SDK - VCR-style fixture management
3
+ *
4
+ * Automatically uses existing fixtures or records new ones on-the-fly.
5
+ *
6
+ * Usage:
7
+ * ```typescript
8
+ * import { createDevtools } from "@agentxjs/devtools";
9
+ *
10
+ * const devtools = createDevtools({
11
+ * fixturesDir: "./fixtures",
12
+ * apiKey: process.env.DEEPRACTICE_API_KEY,
13
+ * });
14
+ *
15
+ * // Has fixture → playback (MockDriver)
16
+ * // No fixture → call API, record, save, return MockDriver
17
+ * const driver = await devtools.driver("hello-test", {
18
+ * message: "Hello!",
19
+ * });
20
+ *
21
+ * await driver.initialize();
22
+ * for await (const event of driver.receive({ content: "Hello" })) {
23
+ * console.log(event);
24
+ * }
25
+ * ```
26
+ */
27
+
28
+ import type { Driver, CreateDriver, DriverConfig } from "@agentxjs/core/driver";
29
+ import type { Fixture } from "./types";
30
+ import { MockDriver } from "./mock/MockDriver";
31
+ import { RecordingDriver } from "./recorder/RecordingDriver";
32
+ import { createLogger } from "commonxjs/logger";
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
35
+ import { join, dirname } from "node:path";
36
+
37
+ const logger = createLogger("devtools/Devtools");
38
+
39
+ /**
40
+ * Devtools configuration
41
+ */
42
+ export interface DevtoolsConfig {
43
+ /**
44
+ * Directory to store/load fixtures
45
+ */
46
+ fixturesDir: string;
47
+
48
+ /**
49
+ * API key for recording (required if recording)
50
+ */
51
+ apiKey?: string;
52
+
53
+ /**
54
+ * API base URL
55
+ */
56
+ baseUrl?: string;
57
+
58
+ /**
59
+ * Default model
60
+ */
61
+ model?: string;
62
+
63
+ /**
64
+ * Default system prompt
65
+ */
66
+ systemPrompt?: string;
67
+
68
+ /**
69
+ * Working directory for tool execution
70
+ */
71
+ cwd?: string;
72
+
73
+ /**
74
+ * Real driver factory for recording
75
+ * If not provided, will try to use @agentxjs/claude-driver
76
+ */
77
+ createDriver?: CreateDriver;
78
+ }
79
+
80
+ /**
81
+ * Options for getting a driver
82
+ */
83
+ export interface DriverOptions {
84
+ /**
85
+ * Message to send if recording is needed
86
+ */
87
+ message: string;
88
+
89
+ /**
90
+ * Override system prompt
91
+ */
92
+ systemPrompt?: string;
93
+
94
+ /**
95
+ * Override working directory
96
+ */
97
+ cwd?: string;
98
+
99
+ /**
100
+ * Force re-record even if fixture exists
101
+ */
102
+ forceRecord?: boolean;
103
+ }
104
+
105
+ /**
106
+ * Devtools SDK
107
+ */
108
+ export class Devtools {
109
+ private config: DevtoolsConfig;
110
+ private realCreateDriver: CreateDriver | null = null;
111
+
112
+ constructor(config: DevtoolsConfig) {
113
+ this.config = config;
114
+ logger.info("Devtools initialized", { fixturesDir: config.fixturesDir });
115
+ }
116
+
117
+ /**
118
+ * Get a driver for the given fixture name
119
+ *
120
+ * - If fixture exists → returns MockDriver with playback
121
+ * - If fixture doesn't exist → records, saves, returns MockDriver
122
+ */
123
+ async driver(name: string, options: DriverOptions): Promise<Driver> {
124
+ const fixturePath = this.getFixturePath(name);
125
+
126
+ // Check if fixture exists
127
+ if (!options.forceRecord && existsSync(fixturePath)) {
128
+ logger.info("Loading existing fixture", { name, path: fixturePath });
129
+ const fixture = await this.loadFixture(fixturePath);
130
+ return new MockDriver({ fixture });
131
+ }
132
+
133
+ // Need to record
134
+ logger.info("Recording new fixture", { name, message: options.message });
135
+ const fixture = await this.record(name, options);
136
+ return new MockDriver({ fixture });
137
+ }
138
+
139
+ /**
140
+ * Get a CreateDriver function that uses a pre-loaded fixture
141
+ *
142
+ * NOTE: This loads the fixture synchronously, so the fixture must exist.
143
+ * For async loading/recording, use driver() instead.
144
+ */
145
+ createDriverForFixture(fixturePath: string): CreateDriver {
146
+ // Load fixture synchronously (requires existing fixture)
147
+ const content = readFileSync(this.getFixturePath(fixturePath), "utf-8");
148
+ const fixture = JSON.parse(content) as Fixture;
149
+
150
+ return (_config: DriverConfig) => {
151
+ return new MockDriver({ fixture });
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Record a fixture
157
+ */
158
+ async record(name: string, options: DriverOptions): Promise<Fixture> {
159
+ const createDriver = await this.getRealCreateDriver();
160
+
161
+ const agentId = `record-${name}`;
162
+
163
+ // Create driver config
164
+ const driverConfig: DriverConfig = {
165
+ apiKey: this.config.apiKey!,
166
+ baseUrl: this.config.baseUrl,
167
+ agentId,
168
+ model: this.config.model,
169
+ systemPrompt: options.systemPrompt || this.config.systemPrompt || "You are a helpful assistant.",
170
+ cwd: options.cwd || this.config.cwd || process.cwd(),
171
+ };
172
+
173
+ // Create real driver
174
+ const realDriver = createDriver(driverConfig);
175
+
176
+ // Wrap with recorder
177
+ const recorder = new RecordingDriver({
178
+ driver: realDriver,
179
+ name,
180
+ description: `Recording of: "${options.message}"`,
181
+ });
182
+
183
+ // Initialize
184
+ await recorder.initialize();
185
+
186
+ try {
187
+ // Build user message
188
+ const userMessage = {
189
+ id: `msg_${Date.now()}`,
190
+ role: "user" as const,
191
+ subtype: "user" as const,
192
+ content: options.message,
193
+ timestamp: Date.now(),
194
+ };
195
+
196
+ // Send message and collect all events
197
+ for await (const event of recorder.receive(userMessage)) {
198
+ logger.debug("Recording event", { type: event.type });
199
+
200
+ // Check for completion
201
+ if (event.type === "message_stop") {
202
+ break;
203
+ }
204
+
205
+ // Check for error
206
+ if (event.type === "error") {
207
+ const errorData = event.data as { message?: string };
208
+ throw new Error(`Recording error: ${errorData.message}`);
209
+ }
210
+ }
211
+
212
+ // Get fixture
213
+ const fixture = recorder.getFixture();
214
+
215
+ // Save fixture
216
+ await this.saveFixture(name, fixture);
217
+
218
+ return fixture;
219
+ } finally {
220
+ // Cleanup
221
+ await recorder.dispose();
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Load a fixture by name
227
+ */
228
+ async load(name: string): Promise<Fixture> {
229
+ const fixturePath = this.getFixturePath(name);
230
+ return this.loadFixture(fixturePath);
231
+ }
232
+
233
+ /**
234
+ * Check if a fixture exists
235
+ */
236
+ exists(name: string): boolean {
237
+ return existsSync(this.getFixturePath(name));
238
+ }
239
+
240
+ /**
241
+ * Delete a fixture
242
+ */
243
+ async delete(name: string): Promise<void> {
244
+ const fixturePath = this.getFixturePath(name);
245
+ if (existsSync(fixturePath)) {
246
+ const { unlink } = await import("node:fs/promises");
247
+ await unlink(fixturePath);
248
+ logger.info("Fixture deleted", { name });
249
+ }
250
+ }
251
+
252
+ // ==================== Private ====================
253
+
254
+ private getFixturePath(name: string): string {
255
+ // If name is already a path, use it directly
256
+ if (name.endsWith(".json")) {
257
+ return name;
258
+ }
259
+ return join(this.config.fixturesDir, `${name}.json`);
260
+ }
261
+
262
+ private async loadFixture(path: string): Promise<Fixture> {
263
+ const content = await readFile(path, "utf-8");
264
+ return JSON.parse(content) as Fixture;
265
+ }
266
+
267
+ private async saveFixture(name: string, fixture: Fixture): Promise<void> {
268
+ const path = this.getFixturePath(name);
269
+ await mkdir(dirname(path), { recursive: true });
270
+ await writeFile(path, JSON.stringify(fixture, null, 2), "utf-8");
271
+ logger.info("Fixture saved", { name, path, eventCount: fixture.events.length });
272
+ }
273
+
274
+ private async getRealCreateDriver(): Promise<CreateDriver> {
275
+ if (this.realCreateDriver) {
276
+ return this.realCreateDriver;
277
+ }
278
+
279
+ if (this.config.createDriver) {
280
+ this.realCreateDriver = this.config.createDriver;
281
+ return this.realCreateDriver;
282
+ }
283
+
284
+ // Validate API key
285
+ if (!this.config.apiKey) {
286
+ throw new Error(
287
+ "apiKey is required for recording. Set it in DevtoolsConfig or provide a createDriver."
288
+ );
289
+ }
290
+
291
+ // Try to import claude-driver
292
+ try {
293
+ const { createClaudeDriver } = await import("@agentxjs/claude-driver");
294
+ this.realCreateDriver = createClaudeDriver;
295
+ return this.realCreateDriver;
296
+ } catch {
297
+ throw new Error("@agentxjs/claude-driver not found. Install it or provide a createDriver.");
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Create a Devtools instance
304
+ */
305
+ export function createDevtools(config: DevtoolsConfig): Devtools {
306
+ return new Devtools(config);
307
+ }
308
+
309
+ // ============================================================================
310
+ // VCR CreateDriver Factory
311
+ // ============================================================================
312
+
313
+ /**
314
+ * Configuration for VCR-aware CreateDriver
315
+ */
316
+ export interface VcrCreateDriverConfig {
317
+ /**
318
+ * Directory to store/load fixtures
319
+ */
320
+ fixturesDir: string;
321
+
322
+ /**
323
+ * Get current fixture name. Return null to skip VCR (use real driver).
324
+ * Called when driver is created.
325
+ */
326
+ getFixtureName: () => string | null;
327
+
328
+ /**
329
+ * API key for recording
330
+ */
331
+ apiKey?: string;
332
+
333
+ /**
334
+ * API base URL
335
+ */
336
+ baseUrl?: string;
337
+
338
+ /**
339
+ * Default model
340
+ */
341
+ model?: string;
342
+
343
+ /**
344
+ * Real driver factory (optional, defaults to @agentxjs/claude-driver)
345
+ */
346
+ createRealDriver?: CreateDriver;
347
+
348
+ /**
349
+ * Called when playback mode is used
350
+ */
351
+ onPlayback?: (fixtureName: string) => void;
352
+
353
+ /**
354
+ * Called when recording mode is used
355
+ */
356
+ onRecording?: (fixtureName: string) => void;
357
+
358
+ /**
359
+ * Called when fixture is saved
360
+ */
361
+ onSaved?: (fixtureName: string, eventCount: number) => void;
362
+ }
363
+
364
+ /**
365
+ * Create a VCR-aware CreateDriver function
366
+ *
367
+ * VCR logic (hardcoded):
368
+ * - Fixture exists → Playback (MockDriver)
369
+ * - Fixture missing → Recording (RecordingDriver) → Auto-save on dispose
370
+ *
371
+ * @example
372
+ * ```typescript
373
+ * let currentFixture: string | null = null;
374
+ *
375
+ * const vcrCreateDriver = createVcrCreateDriver({
376
+ * fixturesDir: "./fixtures",
377
+ * getFixtureName: () => currentFixture,
378
+ * apiKey: process.env.API_KEY,
379
+ * });
380
+ *
381
+ * // Before each test:
382
+ * currentFixture = "test-scenario-name";
383
+ *
384
+ * // Use with server/provider:
385
+ * const provider = await createNodeProvider({
386
+ * createDriver: vcrCreateDriver,
387
+ * });
388
+ * ```
389
+ */
390
+ export function createVcrCreateDriver(config: VcrCreateDriverConfig): CreateDriver {
391
+ const {
392
+ fixturesDir,
393
+ getFixtureName,
394
+ apiKey,
395
+ baseUrl,
396
+ model,
397
+ onPlayback,
398
+ onRecording,
399
+ onSaved,
400
+ } = config;
401
+
402
+ // Real driver factory (must be provided or pre-loaded)
403
+ const realCreateDriver: CreateDriver | null = config.createRealDriver || null;
404
+
405
+ return (driverConfig: DriverConfig): Driver => {
406
+ const fixtureName = getFixtureName();
407
+
408
+ // No fixture name → use real driver without VCR
409
+ if (!fixtureName) {
410
+ if (!apiKey) {
411
+ throw new Error("No fixture name and no API key. Cannot create driver.");
412
+ }
413
+
414
+ // Sync: we need to return immediately, so we create the driver with merged config
415
+ // Note: This path is for non-VCR scenarios
416
+ const createDriver = realCreateDriver || config.createRealDriver;
417
+ if (!createDriver) {
418
+ throw new Error("No createRealDriver provided and claude-driver not loaded yet.");
419
+ }
420
+
421
+ return createDriver({
422
+ ...driverConfig,
423
+ apiKey,
424
+ baseUrl,
425
+ model,
426
+ });
427
+ }
428
+
429
+ const fixturePath = join(fixturesDir, `${fixtureName}.json`);
430
+
431
+ // Fixture exists → Playback (MockDriver)
432
+ if (existsSync(fixturePath)) {
433
+ onPlayback?.(fixtureName);
434
+ logger.info("VCR Playback", { fixtureName });
435
+
436
+ const fixture = JSON.parse(readFileSync(fixturePath, "utf-8")) as Fixture;
437
+ return new MockDriver({ fixture });
438
+ }
439
+
440
+ // No fixture → Recording (RecordingDriver)
441
+ if (!apiKey) {
442
+ throw new Error(
443
+ `No fixture found for "${fixtureName}" and no API key for recording. ` +
444
+ `Either create the fixture or provide an API key.`
445
+ );
446
+ }
447
+
448
+ onRecording?.(fixtureName);
449
+ logger.info("VCR Recording", { fixtureName });
450
+
451
+ // Get real driver factory (sync - must be pre-loaded or provided)
452
+ const createDriver = realCreateDriver || config.createRealDriver;
453
+ if (!createDriver) {
454
+ throw new Error(
455
+ "createRealDriver not available. For async loading, ensure claude-driver is pre-loaded."
456
+ );
457
+ }
458
+
459
+ // Create real driver with merged config
460
+ const realDriver = createDriver({
461
+ ...driverConfig,
462
+ apiKey,
463
+ baseUrl,
464
+ model,
465
+ });
466
+
467
+ // Wrap with RecordingDriver
468
+ const recorder = new RecordingDriver({
469
+ driver: realDriver,
470
+ name: fixtureName,
471
+ description: `VCR recording: ${fixtureName}`,
472
+ });
473
+
474
+ // Auto-save fixture on dispose
475
+ let fixtureSaved = false;
476
+ const originalDispose = recorder.dispose.bind(recorder);
477
+
478
+ recorder.dispose = async () => {
479
+ if (!fixtureSaved && recorder.eventCount > 0) {
480
+ try {
481
+ const fixture = recorder.getFixture();
482
+
483
+ // Ensure directory exists
484
+ const { mkdir, writeFile } = await import("node:fs/promises");
485
+ await mkdir(dirname(fixturePath), { recursive: true });
486
+ await writeFile(fixturePath, JSON.stringify(fixture, null, 2), "utf-8");
487
+
488
+ fixtureSaved = true;
489
+ onSaved?.(fixtureName, recorder.eventCount);
490
+ logger.info("VCR Saved", { fixtureName, eventCount: recorder.eventCount });
491
+ } catch (e) {
492
+ logger.error("VCR Save failed", { fixtureName, error: e });
493
+ }
494
+ }
495
+
496
+ return originalDispose();
497
+ };
498
+
499
+ return recorder;
500
+ };
501
+ }
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @agentxjs/devtools
3
+ *
4
+ * Development Tools for AgentX - VCR-style fixture management
5
+ *
6
+ * ## Quick Start (Recommended)
7
+ *
8
+ * ```typescript
9
+ * import { createDevtools } from "@agentxjs/devtools";
10
+ *
11
+ * const devtools = createDevtools({
12
+ * fixturesDir: "./fixtures",
13
+ * apiKey: process.env.DEEPRACTICE_API_KEY,
14
+ * });
15
+ *
16
+ * // Has fixture → playback (MockDriver)
17
+ * // No fixture → call API, record, save, return MockDriver
18
+ * const driver = await devtools.driver("hello-test", {
19
+ * message: "Hello!",
20
+ * });
21
+ *
22
+ * await driver.initialize();
23
+ * for await (const event of driver.receive(userMessage)) {
24
+ * console.log(event);
25
+ * }
26
+ * ```
27
+ *
28
+ * ## Low-level APIs
29
+ *
30
+ * ```typescript
31
+ * // MockDriver - playback
32
+ * import { MockDriver, createMockDriver } from "@agentxjs/devtools";
33
+ * const driver = new MockDriver({ fixture: myFixture });
34
+ *
35
+ * // RecordingDriver - capture
36
+ * import { createRecordingDriver } from "@agentxjs/devtools";
37
+ * const recorder = createRecordingDriver({ driver: realDriver, name: "test" });
38
+ * ```
39
+ */
40
+
41
+ // Devtools SDK (recommended)
42
+ export {
43
+ Devtools,
44
+ createDevtools,
45
+ createVcrCreateDriver,
46
+ type DevtoolsConfig,
47
+ type DriverOptions,
48
+ type VcrCreateDriverConfig,
49
+ } from "./Devtools";
50
+
51
+ // Mock Driver (low-level)
52
+ export { MockDriver, createMockDriver } from "./mock/MockDriver";
53
+
54
+ // Recording Driver (low-level)
55
+ export {
56
+ RecordingDriver,
57
+ createRecordingDriver,
58
+ type RecordingDriverOptions,
59
+ } from "./recorder/RecordingDriver";
60
+
61
+ // Types
62
+ export type { Fixture, FixtureEvent, MockDriverOptions } from "./types";
63
+
64
+ // Fixtures
65
+ export {
66
+ BUILTIN_FIXTURES,
67
+ SIMPLE_REPLY,
68
+ LONG_REPLY,
69
+ TOOL_CALL,
70
+ ERROR_RESPONSE,
71
+ EMPTY_RESPONSE,
72
+ getFixture,
73
+ listFixtures,
74
+ } from "../fixtures";