@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.
- package/dist/index.d.mts +3331 -0
- package/dist/index.mjs +4205 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +47 -0
- package/src/certification.ts +979 -0
- package/src/chaos.ts +1187 -0
- package/src/fixtures/docs-researcher/definition.ts +342 -0
- package/src/fixtures/docs-researcher/harness.ts +316 -0
- package/src/fixtures/docs-researcher/index.ts +34 -0
- package/src/fixtures/docs-researcher/mcp.ts +126 -0
- package/src/fixtures/travel-planner/definition.ts +167 -0
- package/src/fixtures/travel-planner/deterministic-layers.ts +431 -0
- package/src/fixtures/travel-planner/index.ts +11 -0
- package/src/fixtures/travel-planner/phase2.ts +94 -0
- package/src/fixtures/travel-planner/phase3.ts +159 -0
- package/src/fixtures/travel-planner/phase4.ts +272 -0
- package/src/fixtures/travel-planner/phase5.ts +476 -0
- package/src/fixtures/travel-planner/phase6.ts +923 -0
- package/src/fixtures/travel-planner/phase7.ts +112 -0
- package/src/fixtures/travel-planner/scenarios.ts +85 -0
- package/src/fixtures/travel-planner/subagents-durable.ts +391 -0
- package/src/fixtures/travel-planner/subagents.ts +525 -0
- package/src/index.ts +5 -0
- package/src/scripted-model.ts +303 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Ref, Schema, Stream } from "effect";
|
|
2
|
+
import { AiError, LanguageModel, Response } from "effect/unstable/ai";
|
|
3
|
+
|
|
4
|
+
const ScriptedPartMetadata = Schema.Record(Schema.String, Schema.NullOr(Schema.Json));
|
|
5
|
+
const ScriptedPartBase = {
|
|
6
|
+
metadata: Schema.optionalKey(ScriptedPartMetadata),
|
|
7
|
+
};
|
|
8
|
+
const ScriptedToolCallPart = Schema.Struct({
|
|
9
|
+
...ScriptedPartBase,
|
|
10
|
+
type: Schema.Literal("tool-call"),
|
|
11
|
+
id: Schema.String,
|
|
12
|
+
name: Schema.String,
|
|
13
|
+
params: Schema.Unknown,
|
|
14
|
+
providerExecuted: Schema.optionalKey(Schema.Boolean),
|
|
15
|
+
});
|
|
16
|
+
const ScriptedToolResultPart = Schema.Struct({
|
|
17
|
+
...ScriptedPartBase,
|
|
18
|
+
type: Schema.Literal("tool-result"),
|
|
19
|
+
id: Schema.String,
|
|
20
|
+
name: Schema.String,
|
|
21
|
+
result: Schema.Unknown,
|
|
22
|
+
isFailure: Schema.Boolean,
|
|
23
|
+
providerExecuted: Schema.optionalKey(Schema.Boolean),
|
|
24
|
+
preliminary: Schema.optionalKey(Schema.Boolean),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Schema for encoded, non-streaming Effect AI response parts.
|
|
29
|
+
*
|
|
30
|
+
* Generic Tool payloads remain explicitly unknown here. `LanguageModel.make`
|
|
31
|
+
* performs the toolkit-specific decode when the scripted response is consumed.
|
|
32
|
+
*/
|
|
33
|
+
export const ScriptedGeneratePart = Schema.Union([
|
|
34
|
+
Schema.toEncoded(Response.TextPart),
|
|
35
|
+
Schema.toEncoded(Response.ReasoningPart),
|
|
36
|
+
Schema.toEncoded(Response.ReasoningDeltaPart),
|
|
37
|
+
Schema.toEncoded(Response.ReasoningEndPart),
|
|
38
|
+
ScriptedToolCallPart,
|
|
39
|
+
ScriptedToolResultPart,
|
|
40
|
+
Schema.toEncoded(Response.ToolApprovalRequestPart),
|
|
41
|
+
Schema.toEncoded(Response.FilePart),
|
|
42
|
+
Schema.toEncoded(Response.DocumentSourcePart),
|
|
43
|
+
Schema.toEncoded(Response.UrlSourcePart),
|
|
44
|
+
Schema.toEncoded(Response.ResponseMetadataPart),
|
|
45
|
+
Schema.toEncoded(Response.FinishPart),
|
|
46
|
+
]).annotate({ identifier: "ScriptedGeneratePart" });
|
|
47
|
+
export type ScriptedGeneratePart = typeof ScriptedGeneratePart.Type;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Schema for encoded Effect AI streaming response parts.
|
|
51
|
+
*/
|
|
52
|
+
export const ScriptedStreamPart = Schema.Union([
|
|
53
|
+
Schema.toEncoded(Response.TextStartPart),
|
|
54
|
+
Schema.toEncoded(Response.TextDeltaPart),
|
|
55
|
+
Schema.toEncoded(Response.TextEndPart),
|
|
56
|
+
Schema.toEncoded(Response.ReasoningStartPart),
|
|
57
|
+
Schema.toEncoded(Response.ReasoningDeltaPart),
|
|
58
|
+
Schema.toEncoded(Response.ReasoningEndPart),
|
|
59
|
+
Schema.toEncoded(Response.ToolParamsStartPart),
|
|
60
|
+
Schema.toEncoded(Response.ToolParamsDeltaPart),
|
|
61
|
+
Schema.toEncoded(Response.ToolParamsEndPart),
|
|
62
|
+
ScriptedToolCallPart,
|
|
63
|
+
ScriptedToolResultPart,
|
|
64
|
+
Schema.toEncoded(Response.ToolApprovalRequestPart),
|
|
65
|
+
Schema.toEncoded(Response.FilePart),
|
|
66
|
+
Schema.toEncoded(Response.DocumentSourcePart),
|
|
67
|
+
Schema.toEncoded(Response.UrlSourcePart),
|
|
68
|
+
Schema.toEncoded(Response.ResponseMetadataPart),
|
|
69
|
+
Schema.toEncoded(Response.FinishPart),
|
|
70
|
+
Schema.toEncoded(Response.ErrorPart),
|
|
71
|
+
]).annotate({ identifier: "ScriptedStreamPart" });
|
|
72
|
+
export type ScriptedStreamPart = typeof ScriptedStreamPart.Type;
|
|
73
|
+
|
|
74
|
+
/** Controls whether a scripted stream completes, fails, or waits for interruption. */
|
|
75
|
+
export const ScriptedStreamTermination = Schema.Union([
|
|
76
|
+
Schema.TaggedStruct("Complete", {}),
|
|
77
|
+
Schema.TaggedStruct("Fail", {
|
|
78
|
+
description: Schema.String,
|
|
79
|
+
}),
|
|
80
|
+
Schema.TaggedStruct("Hang", {}),
|
|
81
|
+
]);
|
|
82
|
+
export type ScriptedStreamTermination = typeof ScriptedStreamTermination.Type;
|
|
83
|
+
|
|
84
|
+
/** One non-streaming invocation and the encoded response parts it returns. */
|
|
85
|
+
export const ScriptedGenerateTurn = Schema.TaggedStruct("Generate", {
|
|
86
|
+
parts: Schema.Array(ScriptedGeneratePart),
|
|
87
|
+
});
|
|
88
|
+
export type ScriptedGenerateTurn = typeof ScriptedGenerateTurn.Type;
|
|
89
|
+
|
|
90
|
+
/** One streaming invocation with its encoded parts and terminal behavior. */
|
|
91
|
+
export const ScriptedStreamTurn = Schema.TaggedStruct("Stream", {
|
|
92
|
+
parts: Schema.Array(ScriptedStreamPart),
|
|
93
|
+
termination: ScriptedStreamTermination,
|
|
94
|
+
});
|
|
95
|
+
export type ScriptedStreamTurn = typeof ScriptedStreamTurn.Type;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Serializable grammar for one finite scripted provider invocation.
|
|
99
|
+
*/
|
|
100
|
+
export const ScriptedTurn = Schema.Union([ScriptedGenerateTurn, ScriptedStreamTurn]);
|
|
101
|
+
export type ScriptedTurn = typeof ScriptedTurn.Type;
|
|
102
|
+
|
|
103
|
+
export type ScriptedRequestKind = "generate" | "stream";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A request after Effect AI has normalized its prompt, tools, response format,
|
|
107
|
+
* tool choice, span, and incremental-response fields.
|
|
108
|
+
*/
|
|
109
|
+
export interface ScriptedRequest {
|
|
110
|
+
readonly kind: ScriptedRequestKind;
|
|
111
|
+
readonly options: LanguageModel.ProviderOptions;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Optional request assertions and stream lifecycle effects for one scripted turn. */
|
|
115
|
+
export interface ScriptedTurnHooks {
|
|
116
|
+
/** Runs against the normalized provider request before producing the response. */
|
|
117
|
+
readonly assertRequest?: (
|
|
118
|
+
request: LanguageModel.ProviderOptions,
|
|
119
|
+
) => Effect.Effect<void, AiError.AiError> | void;
|
|
120
|
+
/** Runs immediately before the scripted stream begins emitting parts. */
|
|
121
|
+
readonly onStreamStart?: Effect.Effect<void> | undefined;
|
|
122
|
+
/** Runs when the scripted stream completes, fails, or is interrupted. */
|
|
123
|
+
readonly onStreamFinalize?: Effect.Effect<void> | undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Runtime hooks are deliberately separate from the serializable turn grammar.
|
|
128
|
+
*/
|
|
129
|
+
export type ScriptedTurnInput = ScriptedTurn & ScriptedTurnHooks;
|
|
130
|
+
|
|
131
|
+
interface ScriptState {
|
|
132
|
+
readonly remaining: ReadonlyArray<ScriptedTurnInput>;
|
|
133
|
+
readonly requests: ReadonlyArray<ScriptedRequest>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const scriptedError = (method: string, description: string): AiError.AiError =>
|
|
137
|
+
AiError.AiError.make({
|
|
138
|
+
module: "@effect-agent/testing/ScriptedModel",
|
|
139
|
+
method,
|
|
140
|
+
reason: AiError.UnknownError.make({ description }),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const runAssertion = Effect.fn("ScriptedModel.runAssertion")((
|
|
144
|
+
assertion: ScriptedTurnHooks["assertRequest"],
|
|
145
|
+
request: LanguageModel.ProviderOptions,
|
|
146
|
+
): Effect.Effect<void, AiError.AiError> => {
|
|
147
|
+
if (assertion === undefined) {
|
|
148
|
+
return Effect.void;
|
|
149
|
+
}
|
|
150
|
+
return Effect.suspend(() => {
|
|
151
|
+
const result = assertion(request);
|
|
152
|
+
return Effect.isEffect(result) ? result : Effect.void;
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const takeTurn = Effect.fn("ScriptedModel.takeTurn")(
|
|
157
|
+
(
|
|
158
|
+
state: Ref.Ref<ScriptState>,
|
|
159
|
+
kind: ScriptedRequestKind,
|
|
160
|
+
options: LanguageModel.ProviderOptions,
|
|
161
|
+
): Effect.Effect<ScriptedTurnInput, AiError.AiError> =>
|
|
162
|
+
Ref.modify(state, (current) => {
|
|
163
|
+
const turn = current.remaining[0];
|
|
164
|
+
if (turn === undefined) {
|
|
165
|
+
return [
|
|
166
|
+
undefined,
|
|
167
|
+
{
|
|
168
|
+
...current,
|
|
169
|
+
requests: [...current.requests, { kind, options }],
|
|
170
|
+
},
|
|
171
|
+
] as const;
|
|
172
|
+
}
|
|
173
|
+
return [
|
|
174
|
+
turn,
|
|
175
|
+
{
|
|
176
|
+
remaining: current.remaining.slice(1),
|
|
177
|
+
requests: [...current.requests, { kind, options }],
|
|
178
|
+
},
|
|
179
|
+
] as const;
|
|
180
|
+
}).pipe(
|
|
181
|
+
Effect.flatMap((turn) =>
|
|
182
|
+
turn === undefined
|
|
183
|
+
? Effect.fail(scriptedError(kind, `Script exhausted before the ${kind} request`))
|
|
184
|
+
: Effect.succeed(turn),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const requireGenerateTurn = (
|
|
190
|
+
turn: ScriptedTurnInput,
|
|
191
|
+
): Effect.Effect<ScriptedGenerateTurn & ScriptedTurnHooks, AiError.AiError> =>
|
|
192
|
+
turn._tag === "Generate"
|
|
193
|
+
? Effect.succeed(turn)
|
|
194
|
+
: Effect.fail(scriptedError("generate", `Expected a Generate turn but found ${turn._tag}`));
|
|
195
|
+
|
|
196
|
+
const requireStreamTurn = (
|
|
197
|
+
turn: ScriptedTurnInput,
|
|
198
|
+
): Effect.Effect<ScriptedStreamTurn & ScriptedTurnHooks, AiError.AiError> =>
|
|
199
|
+
turn._tag === "Stream"
|
|
200
|
+
? Effect.succeed(turn)
|
|
201
|
+
: Effect.fail(scriptedError("stream", `Expected a Stream turn but found ${turn._tag}`));
|
|
202
|
+
|
|
203
|
+
const streamForTurn = (
|
|
204
|
+
turn: ScriptedStreamTurn & ScriptedTurnHooks,
|
|
205
|
+
): Stream.Stream<Response.StreamPartEncoded, AiError.AiError> => {
|
|
206
|
+
let stream: Stream.Stream<Response.StreamPartEncoded, AiError.AiError> = Stream.fromIterable(
|
|
207
|
+
turn.parts,
|
|
208
|
+
);
|
|
209
|
+
switch (turn.termination._tag) {
|
|
210
|
+
case "Complete": {
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case "Fail": {
|
|
214
|
+
stream = stream.pipe(
|
|
215
|
+
Stream.concat(Stream.fail(scriptedError("stream", turn.termination.description))),
|
|
216
|
+
);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "Hang": {
|
|
220
|
+
stream = stream.pipe(Stream.concat(Stream.never));
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (turn.onStreamStart !== undefined) {
|
|
225
|
+
stream = Stream.fromEffectDrain(turn.onStreamStart).pipe(Stream.concat(stream));
|
|
226
|
+
}
|
|
227
|
+
if (turn.onStreamFinalize !== undefined) {
|
|
228
|
+
stream = stream.pipe(Stream.ensuring(turn.onStreamFinalize));
|
|
229
|
+
}
|
|
230
|
+
return stream;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Inspection service for a deterministic LanguageModel backed by finite scripted turns. */
|
|
234
|
+
export class ScriptedModel extends Context.Service<
|
|
235
|
+
ScriptedModel,
|
|
236
|
+
{
|
|
237
|
+
/** Normalized provider requests captured in invocation order. */
|
|
238
|
+
readonly requests: Effect.Effect<ReadonlyArray<ScriptedRequest>>;
|
|
239
|
+
/** Number of scripted turns not yet consumed. */
|
|
240
|
+
readonly remaining: Effect.Effect<number>;
|
|
241
|
+
/** Fails with `AiError` when any scripted turns remain. */
|
|
242
|
+
readonly assertExhausted: Effect.Effect<void, AiError.AiError>;
|
|
243
|
+
}
|
|
244
|
+
>()("@effect-agent/testing/ScriptedModel") {
|
|
245
|
+
/**
|
|
246
|
+
* Provides the native Effect AI `LanguageModel` and this inspection service.
|
|
247
|
+
* Supplying the extra inspection service does not add it to model-call
|
|
248
|
+
* requirements. Each model invocation consumes one turn before assertion and
|
|
249
|
+
* turn-kind validation.
|
|
250
|
+
*/
|
|
251
|
+
static layer(
|
|
252
|
+
turns: ReadonlyArray<ScriptedTurnInput>,
|
|
253
|
+
): Layer.Layer<LanguageModel.LanguageModel | ScriptedModel, never, never> {
|
|
254
|
+
return Layer.effectContext(
|
|
255
|
+
Effect.gen(function* () {
|
|
256
|
+
const state = yield* Ref.make<ScriptState>({
|
|
257
|
+
remaining: [...turns],
|
|
258
|
+
requests: [],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const languageModel = yield* LanguageModel.make({
|
|
262
|
+
generateText: (options) =>
|
|
263
|
+
Effect.gen(function* () {
|
|
264
|
+
const turn = yield* takeTurn(state, "generate", options);
|
|
265
|
+
yield* runAssertion(turn.assertRequest, options);
|
|
266
|
+
const generateTurn = yield* requireGenerateTurn(turn);
|
|
267
|
+
return [...generateTurn.parts];
|
|
268
|
+
}),
|
|
269
|
+
streamText: (options) =>
|
|
270
|
+
Stream.unwrap(
|
|
271
|
+
Effect.gen(function* () {
|
|
272
|
+
const turn = yield* takeTurn(state, "stream", options);
|
|
273
|
+
yield* runAssertion(turn.assertRequest, options);
|
|
274
|
+
const streamTurn = yield* requireStreamTurn(turn);
|
|
275
|
+
return streamForTurn(streamTurn);
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const inspection = ScriptedModel.of({
|
|
281
|
+
requests: Ref.get(state).pipe(Effect.map((current) => current.requests)),
|
|
282
|
+
remaining: Ref.get(state).pipe(Effect.map((current) => current.remaining.length)),
|
|
283
|
+
assertExhausted: Ref.get(state).pipe(
|
|
284
|
+
Effect.flatMap((current) =>
|
|
285
|
+
current.remaining.length === 0
|
|
286
|
+
? Effect.void
|
|
287
|
+
: Effect.fail(
|
|
288
|
+
scriptedError(
|
|
289
|
+
"assertExhausted",
|
|
290
|
+
`${current.remaining.length} scripted turn(s) remain`,
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return Context.make(LanguageModel.LanguageModel, languageModel).pipe(
|
|
298
|
+
Context.add(ScriptedModel, inspection),
|
|
299
|
+
);
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|