@effect-agent/testing 0.0.1-beta.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.
@@ -0,0 +1,525 @@
1
+ import { Subagent, SubagentPolicy, SubagentRuntime } from "@effect-agent/capabilities";
2
+ import { Agent, AgentPolicy } from "@effect-agent/core";
3
+ import type { RuntimeBinding } from "@effect-agent/engine";
4
+ import { Context, Deferred, Effect, Layer, Ref, Schema, Stream } from "effect";
5
+ import { LanguageModel, Model, type Response, Tool, Toolkit } from "effect/unstable/ai";
6
+
7
+ import type { ScriptedTurnInput } from "../../scripted-model.ts";
8
+ import { AirportCode } from "./definition.ts";
9
+ import { CatalogLifecycle } from "./deterministic-layers.ts";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Destination Researcher: the S1 specialist child Agent
13
+ // (spec/subagents.md §17 "deterministic scripted Travel Planner specialist
14
+ // delegation"). The researcher is a normal Agent with one deterministic
15
+ // travel-service Tool; it never becomes a second child loop (SUB-001/003).
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export class DestinationQuery extends Schema.Class<DestinationQuery>("DestinationQuery")({
19
+ destination: AirportCode,
20
+ }) {}
21
+
22
+ export class DestinationFacts extends Schema.Class<DestinationFacts>("DestinationFacts")({
23
+ destination: AirportCode,
24
+ highlights: Schema.Array(Schema.String),
25
+ advisory: Schema.NonEmptyString,
26
+ }) {}
27
+
28
+ export class DestinationGuideUnavailable extends Schema.TaggedErrorClass<DestinationGuideUnavailable>()(
29
+ "DestinationGuideUnavailable",
30
+ {
31
+ destination: AirportCode,
32
+ message: Schema.String,
33
+ },
34
+ ) {}
35
+
36
+ export class DestinationGuide extends Context.Service<
37
+ DestinationGuide,
38
+ {
39
+ readonly lookup: (
40
+ query: DestinationQuery,
41
+ ) => Effect.Effect<DestinationFacts, DestinationGuideUnavailable>;
42
+ }
43
+ >()("@effect-agent/testing/travel-planner/DestinationGuide") {}
44
+
45
+ export const LookupDestination = Tool.make("lookup_destination", {
46
+ parameters: DestinationQuery,
47
+ success: DestinationFacts,
48
+ failure: DestinationGuideUnavailable,
49
+ failureMode: "error",
50
+ dependencies: [DestinationGuide],
51
+ });
52
+
53
+ export const DestinationResearcherToolkit = Toolkit.make(LookupDestination);
54
+ export const DestinationResearcherToolkitLayer = DestinationResearcherToolkit.toLayer({
55
+ lookup_destination: (query) => Effect.flatMap(DestinationGuide, (guide) => guide.lookup(query)),
56
+ });
57
+
58
+ export class DestinationBrief extends Schema.Class<DestinationBrief>("DestinationBrief")({
59
+ destination: AirportCode,
60
+ focus: Schema.NonEmptyString,
61
+ }) {}
62
+
63
+ export class DestinationReport extends Schema.Class<DestinationReport>("DestinationReport")({
64
+ destination: AirportCode,
65
+ highlights: Schema.Array(Schema.String),
66
+ advisory: Schema.NonEmptyString,
67
+ }) {}
68
+
69
+ export const DestinationResearcher = Agent.define("destination-researcher", {
70
+ input: DestinationBrief,
71
+ output: DestinationReport,
72
+ instructions:
73
+ "Consult lookup_destination exactly once for the briefed airport, then return only a JSON destination report.",
74
+ toolkit: DestinationResearcherToolkit,
75
+ policy: AgentPolicy.make({
76
+ maxTurns: 2,
77
+ maxToolCalls: 1,
78
+ maxDuration: "30 seconds",
79
+ toolConcurrency: 1,
80
+ }),
81
+ description: "Research one candidate destination with the deterministic travel guide.",
82
+ metadata: { deploymentClass: "E", phase: "S1" },
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Deterministic guide data and Layer (deterministic-layers.ts conventions).
87
+ // ---------------------------------------------------------------------------
88
+
89
+ const decodeAirportCode = Schema.decodeSync(AirportCode);
90
+
91
+ const guideFacts = new Map<string, DestinationFacts>([
92
+ [
93
+ "LHR",
94
+ DestinationFacts.make({
95
+ destination: decodeAirportCode("LHR"),
96
+ highlights: ["Barbican brutalism walk", "Kew glasshouse survey"],
97
+ advisory: "London favors museum mornings and riverside evenings.",
98
+ }),
99
+ ],
100
+ [
101
+ "CDG",
102
+ DestinationFacts.make({
103
+ destination: decodeAirportCode("CDG"),
104
+ highlights: ["Marais passage crawl", "Seine bookstall loop"],
105
+ advisory: "Paris rewards early galleries and late cafes.",
106
+ }),
107
+ ],
108
+ ]);
109
+
110
+ /** Deterministic guide lookup shared by the default and test-local guide Layers. */
111
+ export const destinationLookup = (
112
+ query: DestinationQuery,
113
+ ): Effect.Effect<DestinationFacts, DestinationGuideUnavailable> => {
114
+ const facts = guideFacts.get(query.destination);
115
+ return facts === undefined
116
+ ? Effect.fail(
117
+ DestinationGuideUnavailable.make({
118
+ destination: query.destination,
119
+ message: "No deterministic guide entry exists for this destination.",
120
+ }),
121
+ )
122
+ : Effect.succeed(facts);
123
+ };
124
+
125
+ const requireDestinationFacts = (destination: string): DestinationFacts => {
126
+ const facts = guideFacts.get(destination);
127
+ if (facts === undefined) {
128
+ throw new Error(`No deterministic guide entry exists for destination ${destination}`);
129
+ }
130
+ return facts;
131
+ };
132
+
133
+ /** The report the scripted researcher writes after consulting the guide. */
134
+ export const destinationReportFor = (destination: string): DestinationReport => {
135
+ const facts = requireDestinationFacts(destination);
136
+ return DestinationReport.make({
137
+ destination: facts.destination,
138
+ highlights: facts.highlights,
139
+ advisory: facts.advisory,
140
+ });
141
+ };
142
+
143
+ export const encodedDestinationReport = (destination: string): string =>
144
+ JSON.stringify(Schema.encodeSync(DestinationReport)(destinationReportFor(destination)));
145
+
146
+ export const DestinationGuideLayer = Layer.effect(
147
+ DestinationGuide,
148
+ Effect.gen(function* () {
149
+ const lifecycle = yield* CatalogLifecycle;
150
+ yield* Effect.acquireRelease(lifecycle.markAcquired, () => lifecycle.markFinalized);
151
+ return DestinationGuide.of({ lookup: destinationLookup });
152
+ }),
153
+ );
154
+
155
+ /** Child-side construction requirements of the delegation handler Layer. */
156
+ export const DestinationResearchSupportLayer = Layer.mergeAll(
157
+ DestinationResearcherToolkitLayer,
158
+ DestinationGuideLayer,
159
+ );
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Delegation Definition (spec/subagents.md §4): the coordinator sees exactly
163
+ // one Effect AI Tool with explicit input/result projections and finite bounds.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ export class DestinationResearchRequest extends Schema.Class<DestinationResearchRequest>(
167
+ "DestinationResearchRequest",
168
+ )({
169
+ destination: AirportCode,
170
+ focus: Schema.NonEmptyString,
171
+ }) {}
172
+
173
+ export class DestinationResearchFindings extends Schema.Class<DestinationResearchFindings>(
174
+ "DestinationResearchFindings",
175
+ )({
176
+ destination: AirportCode,
177
+ summary: Schema.NonEmptyString,
178
+ }) {}
179
+
180
+ export class DestinationResearchFailed extends Schema.TaggedErrorClass<DestinationResearchFailed>()(
181
+ "DestinationResearchFailed",
182
+ {
183
+ childErrorTag: Schema.NonEmptyString,
184
+ },
185
+ ) {}
186
+
187
+ /**
188
+ * Deterministic delegation-admission choreography seam. `prepareInput` awaits
189
+ * this gate before the handler reserves budget or spawns, so tests can order
190
+ * concurrent delegation preflights without sleeps. It also keeps the
191
+ * projection's construction requirements honestly visible in the handler
192
+ * Layer's `R` (spec/subagents.md §4.1). The open Layer never waits.
193
+ */
194
+ export class ResearchDispatchGate extends Context.Service<
195
+ ResearchDispatchGate,
196
+ { readonly awaitDispatch: (destination: string) => Effect.Effect<void> }
197
+ >()("@effect-agent/testing/travel-planner/ResearchDispatchGate") {
198
+ static readonly layerOpen = Layer.succeed(
199
+ this,
200
+ ResearchDispatchGate.of({ awaitDispatch: () => Effect.void }),
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Finite per-invocation bounds (SUB-009): each child may use two Turns and
206
+ * one Tool Call; the parent Run may establish at most two children with at
207
+ * most two running concurrently.
208
+ */
209
+ export const destinationResearchPolicy = SubagentPolicy.make({
210
+ maxChildren: 2,
211
+ maxConcurrency: 2,
212
+ maxTurns: 2,
213
+ maxToolCalls: 1,
214
+ maxDuration: "10 seconds",
215
+ });
216
+
217
+ export const destinationResearchDelegation = Subagent.define("delegate_destination_research", {
218
+ description:
219
+ "Research one candidate destination with the deterministic travel guide and return a bounded finding.",
220
+ target: DestinationResearcher,
221
+ parameters: DestinationResearchRequest,
222
+ success: DestinationResearchFindings,
223
+ failure: DestinationResearchFailed,
224
+ prepareInput: (request) =>
225
+ Effect.gen(function* () {
226
+ const gate = yield* ResearchDispatchGate;
227
+ yield* gate.awaitDispatch(request.destination);
228
+ return DestinationBrief.make({
229
+ destination: request.destination,
230
+ focus: `research:${request.focus}`,
231
+ });
232
+ }),
233
+ // The explicit declassification boundary (SUB-015): only the advisory
234
+ // crosses to the parent; guide highlights stay in the child Conversation.
235
+ projectResult: (report) =>
236
+ Effect.succeed(
237
+ DestinationResearchFindings.make({
238
+ destination: report.destination,
239
+ summary: report.advisory,
240
+ }),
241
+ ),
242
+ policy: destinationResearchPolicy,
243
+ });
244
+
245
+ /** Total mapping from every expected child Run failure to the declared Tool failure (SUB-028). */
246
+ export const mapResearchChildFailure = (failure: {
247
+ readonly _tag: string;
248
+ }): DestinationResearchFailed => DestinationResearchFailed.make({ childErrorTag: failure._tag });
249
+
250
+ /** Runtime wiring: pair the immutable delegation with one explicit child Binding. */
251
+ export const destinationResearchHandlersLayer = <Provider, ModelProvides, ModelRequires>(
252
+ childBinding: RuntimeBinding<
253
+ typeof DestinationBrief,
254
+ typeof DestinationReport,
255
+ string,
256
+ Toolkit.Tools<typeof DestinationResearcherToolkit>,
257
+ Provider,
258
+ ModelProvides,
259
+ ModelRequires
260
+ >,
261
+ ) =>
262
+ SubagentRuntime.layer(destinationResearchDelegation, childBinding, {
263
+ mapChildFailure: mapResearchChildFailure,
264
+ });
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Travel Coordinator: the parent Agent that delegates destination research.
268
+ // ---------------------------------------------------------------------------
269
+
270
+ export class ResearchMission extends Schema.Class<ResearchMission>("ResearchMission")({
271
+ request: Schema.NonEmptyString,
272
+ candidates: Schema.Array(AirportCode).check(Schema.isMinLength(1)),
273
+ }) {}
274
+
275
+ export class DestinationRecommendation extends Schema.Class<DestinationRecommendation>(
276
+ "DestinationRecommendation",
277
+ )({
278
+ destination: AirportCode,
279
+ summary: Schema.NonEmptyString,
280
+ }) {}
281
+
282
+ export class DestinationShortlist extends Schema.Class<DestinationShortlist>(
283
+ "DestinationShortlist",
284
+ )({
285
+ recommendations: Schema.Array(DestinationRecommendation),
286
+ nextAction: Schema.Literal("review"),
287
+ }) {}
288
+
289
+ /** Parent-only transcript markers used to prove child context isolation (SUB-006/015). */
290
+ export const coordinatorConfidentialMarker = "coordinator-vault-7q42";
291
+ export const missionConfidentialMarker = "traveler-dossier-19f";
292
+
293
+ export const TravelCoordinatorToolkit = Toolkit.make(destinationResearchDelegation.tool);
294
+
295
+ export const TravelCoordinator = Agent.define("travel-coordinator", {
296
+ input: ResearchMission,
297
+ output: DestinationShortlist,
298
+ instructions: [
299
+ "You are the Effect Agent Travel Planner S1 delegation coordinator.",
300
+ `Coordinator-only context: ${coordinatorConfidentialMarker}.`,
301
+ "Call delegate_destination_research once per candidate in one Tool batch.",
302
+ "Return only a JSON shortlist built from the delegated findings. This is read-only planning.",
303
+ ].join("\n"),
304
+ toolkit: TravelCoordinatorToolkit,
305
+ policy: AgentPolicy.make({
306
+ maxTurns: 2,
307
+ maxToolCalls: 3,
308
+ maxDuration: "30 seconds",
309
+ toolConcurrency: 3,
310
+ }),
311
+ description:
312
+ "Coordinate bounded destination research through one declared attached delegation Tool.",
313
+ metadata: { deploymentClass: "E", phase: "S1" },
314
+ });
315
+
316
+ export const researchMission = Schema.decodeSync(ResearchMission)({
317
+ request: `Shortlist one September culture city; keep ${missionConfidentialMarker} inside the coordinator conversation.`,
318
+ candidates: ["LHR", "CDG"],
319
+ });
320
+
321
+ export const expectedDestinationShortlist = DestinationShortlist.make({
322
+ recommendations: researchMission.candidates.map((destination) =>
323
+ DestinationRecommendation.make({
324
+ destination,
325
+ summary: requireDestinationFacts(destination).advisory,
326
+ }),
327
+ ),
328
+ nextAction: "review",
329
+ });
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Scripted turns (scenarios.ts conventions).
333
+ // ---------------------------------------------------------------------------
334
+
335
+ const scriptedUsage = { inputTokens: { total: 96 }, outputTokens: { total: 64 } };
336
+
337
+ export interface DestinationResearchCall {
338
+ readonly id: string;
339
+ readonly destination: string;
340
+ readonly focus: string;
341
+ }
342
+
343
+ /** One coordinator Turn that declares the given delegation Tool Calls in order. */
344
+ export const coordinatorResearchTurn = (
345
+ calls: ReadonlyArray<DestinationResearchCall>,
346
+ ): ScriptedTurnInput => ({
347
+ _tag: "Stream",
348
+ parts: [
349
+ ...calls.map((call) => ({
350
+ type: "tool-call" as const,
351
+ id: call.id,
352
+ name: "delegate_destination_research",
353
+ params: { destination: call.destination, focus: call.focus },
354
+ })),
355
+ { type: "finish" as const, reason: "tool-calls" as const, usage: scriptedUsage },
356
+ ],
357
+ termination: { _tag: "Complete" },
358
+ });
359
+
360
+ /** The coordinator's final structured-output Turn. */
361
+ export const coordinatorShortlistTurn = (shortlist: DestinationShortlist): ScriptedTurnInput => ({
362
+ _tag: "Stream",
363
+ parts: [
364
+ { type: "text-start", id: "shortlist" },
365
+ {
366
+ type: "text-delta",
367
+ id: "shortlist",
368
+ delta: JSON.stringify(Schema.encodeSync(DestinationShortlist)(shortlist)),
369
+ },
370
+ { type: "text-end", id: "shortlist" },
371
+ { type: "finish", reason: "stop", usage: scriptedUsage },
372
+ ],
373
+ termination: { _tag: "Complete" },
374
+ });
375
+
376
+ /** Static researcher script for single-child tests: one guide lookup, then the report. */
377
+ export const researcherHappyPathTurns = (
378
+ destination: string,
379
+ ): readonly [ScriptedTurnInput, ScriptedTurnInput] => [
380
+ {
381
+ _tag: "Stream",
382
+ parts: [
383
+ {
384
+ type: "tool-call",
385
+ id: `lookup-${destination}`,
386
+ name: "lookup_destination",
387
+ params: { destination },
388
+ },
389
+ { type: "finish", reason: "tool-calls", usage: scriptedUsage },
390
+ ],
391
+ termination: { _tag: "Complete" },
392
+ },
393
+ {
394
+ _tag: "Stream",
395
+ parts: [
396
+ { type: "text-start", id: "destination-report" },
397
+ {
398
+ type: "text-delta",
399
+ id: "destination-report",
400
+ delta: encodedDestinationReport(destination),
401
+ },
402
+ { type: "text-end", id: "destination-report" },
403
+ { type: "finish", reason: "stop", usage: scriptedUsage },
404
+ ],
405
+ termination: { _tag: "Complete" },
406
+ },
407
+ ];
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Destination-keyed researcher model for parallel-children tests.
411
+ // ---------------------------------------------------------------------------
412
+
413
+ /**
414
+ * Deterministic controls for parallel researcher children whose completions
415
+ * are released in a caller-selected order (ReverseCompletionToolkitLayer
416
+ * pattern). This is intentionally a test fixture: it uses no clock or sleep.
417
+ */
418
+ export interface DestinationResearcherControls {
419
+ /** Await the first model request of the child researching this destination. */
420
+ readonly awaitStarted: (destination: string) => Effect.Effect<void>;
421
+ /** Allow the child researching this destination to produce its final report. */
422
+ readonly release: (destination: string) => Effect.Effect<void>;
423
+ /** JSON-encoded first-Turn child prompts in arrival order (isolation evidence). */
424
+ readonly prompts: Effect.Effect<ReadonlyArray<string>>;
425
+ }
426
+
427
+ interface ResearcherGates {
428
+ readonly started: Deferred.Deferred<void>;
429
+ readonly release: Deferred.Deferred<void>;
430
+ }
431
+
432
+ const researcherLookupParts = (destination: string): ReadonlyArray<Response.StreamPartEncoded> => [
433
+ {
434
+ type: "tool-call",
435
+ id: `lookup-${destination}`,
436
+ name: "lookup_destination",
437
+ params: { destination },
438
+ providerExecuted: false,
439
+ },
440
+ { type: "finish", reason: "tool-calls", usage: scriptedUsage },
441
+ ];
442
+
443
+ const researcherReportParts = (destination: string): ReadonlyArray<Response.StreamPartEncoded> => [
444
+ { type: "text-start", id: "destination-report" },
445
+ { type: "text-delta", id: "destination-report", delta: encodedDestinationReport(destination) },
446
+ { type: "text-end", id: "destination-report" },
447
+ { type: "finish", reason: "stop", usage: scriptedUsage },
448
+ ];
449
+
450
+ /**
451
+ * Build a deterministic researcher Model whose per-child behavior is keyed by
452
+ * the destination named in the child's own prompt: Turn one records the
453
+ * prompt, signals `started`, and calls the guide Tool; Turn two waits for the
454
+ * caller's `release` before writing the report. Each child Run builds the
455
+ * Model Layer inside its own scope, so one `CatalogLifecycle` acquisition and
456
+ * finalization is observed per child — the same acquire/release counting the
457
+ * catalog Layers use to prove interruption reaches every finalizer.
458
+ */
459
+ export const makeDestinationResearcherModel = (destinations: ReadonlyArray<string>) =>
460
+ Effect.gen(function* () {
461
+ const lifecycle = yield* CatalogLifecycle;
462
+ const prompts = yield* Ref.make<ReadonlyArray<string>>([]);
463
+ const gates = new Map<string, ResearcherGates>();
464
+ for (const destination of destinations) {
465
+ gates.set(destination, {
466
+ started: yield* Deferred.make<void>(),
467
+ release: yield* Deferred.make<void>(),
468
+ });
469
+ }
470
+ const gatesFor = (destination: string): Effect.Effect<ResearcherGates> =>
471
+ Effect.suspend(() => {
472
+ const entry = gates.get(destination);
473
+ return entry === undefined
474
+ ? Effect.die(new Error(`No researcher gates exist for destination ${destination}`))
475
+ : Effect.succeed(entry);
476
+ });
477
+ const controls: DestinationResearcherControls = {
478
+ awaitStarted: (destination) =>
479
+ gatesFor(destination).pipe(Effect.flatMap((entry) => Deferred.await(entry.started))),
480
+ release: (destination) =>
481
+ gatesFor(destination).pipe(
482
+ Effect.flatMap((entry) => Deferred.succeed(entry.release, undefined)),
483
+ Effect.asVoid,
484
+ ),
485
+ prompts: Ref.get(prompts),
486
+ };
487
+ const model = Model.make(
488
+ "scripted",
489
+ "destination-researcher-scripted",
490
+ Layer.effect(
491
+ LanguageModel.LanguageModel,
492
+ Effect.gen(function* () {
493
+ yield* Effect.acquireRelease(lifecycle.markAcquired, () => lifecycle.markFinalized);
494
+ const turn = yield* Ref.make(0);
495
+ return yield* LanguageModel.make({
496
+ generateText: () => Effect.succeed([]),
497
+ streamText: (options) =>
498
+ Stream.unwrap(
499
+ Effect.gen(function* () {
500
+ const promptJson = JSON.stringify(options.prompt.content);
501
+ const destination = destinations.find((candidate) =>
502
+ promptJson.includes(candidate),
503
+ );
504
+ if (destination === undefined) {
505
+ return yield* Effect.die(
506
+ new Error("The researcher prompt names no scripted destination"),
507
+ );
508
+ }
509
+ const entry = yield* gatesFor(destination);
510
+ const index = yield* Ref.getAndUpdate(turn, (value) => value + 1);
511
+ if (index === 0) {
512
+ yield* Ref.update(prompts, (previous) => [...previous, promptJson]);
513
+ yield* Deferred.succeed(entry.started, undefined);
514
+ return Stream.fromIterable(researcherLookupParts(destination));
515
+ }
516
+ yield* Deferred.await(entry.release);
517
+ return Stream.fromIterable(researcherReportParts(destination));
518
+ }),
519
+ ),
520
+ });
521
+ }),
522
+ ),
523
+ );
524
+ return { controls, model };
525
+ });
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./certification.ts";
2
+ export * from "./chaos.ts";
3
+ export * from "./fixtures/docs-researcher/index.ts";
4
+ export * from "./fixtures/travel-planner/index.ts";
5
+ export * from "./scripted-model.ts";