@effect-agent/testing 0.1.0-beta.9 → 0.1.0-beta.91

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.
Files changed (55) hide show
  1. package/dist/Certification.d.mts +106 -0
  2. package/dist/Certification.mjs +633 -0
  3. package/dist/Certification.mjs.map +1 -0
  4. package/dist/Chaos.d.mts +126 -0
  5. package/dist/Chaos.mjs +755 -0
  6. package/dist/Chaos.mjs.map +1 -0
  7. package/dist/CodeExecutorConformance.d.mts +33 -0
  8. package/dist/CodeExecutorConformance.mjs +231 -0
  9. package/dist/CodeExecutorConformance.mjs.map +1 -0
  10. package/dist/CodeExecutorSubstitute.d.mts +21 -0
  11. package/dist/CodeExecutorSubstitute.mjs +368 -0
  12. package/dist/CodeExecutorSubstitute.mjs.map +1 -0
  13. package/dist/DocsResearcher.d.mts +276 -0
  14. package/dist/DocsResearcher.mjs +490 -0
  15. package/dist/DocsResearcher.mjs.map +1 -0
  16. package/dist/ScriptedModel-DAvxIiud.d.mts +220 -0
  17. package/dist/ScriptedModel.d.mts +2 -0
  18. package/dist/ScriptedModel.mjs +155 -0
  19. package/dist/ScriptedModel.mjs.map +1 -0
  20. package/dist/TravelPlanner.d.mts +1680 -0
  21. package/dist/TravelPlanner.mjs +1963 -0
  22. package/dist/TravelPlanner.mjs.map +1 -0
  23. package/dist/deterministic-layers-D5owIoke.mjs +358 -0
  24. package/dist/deterministic-layers-D5owIoke.mjs.map +1 -0
  25. package/dist/index.d.mts +2 -3408
  26. package/dist/index.mjs +2 -4771
  27. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  28. package/package.json +1 -48
  29. package/src/{certification.ts → Certification.ts} +366 -171
  30. package/src/{chaos.ts → Chaos.ts} +241 -127
  31. package/src/{code-executor-conformance.ts → CodeExecutorConformance.ts} +30 -7
  32. package/src/{code-executor-substitute.ts → CodeExecutorSubstitute.ts} +113 -46
  33. package/src/{fixtures/docs-researcher/index.ts → DocsResearcher.ts} +68 -5
  34. package/src/{scripted-model.ts → ScriptedModel.ts} +25 -29
  35. package/src/TravelPlanner.ts +232 -0
  36. package/src/fixtures/docs-researcher/definition.ts +20 -11
  37. package/src/fixtures/docs-researcher/harness.ts +41 -34
  38. package/src/fixtures/docs-researcher/mcp.ts +49 -3
  39. package/src/fixtures/travel-planner/definition.ts +15 -2
  40. package/src/fixtures/travel-planner/deterministic-layers.ts +47 -4
  41. package/src/fixtures/travel-planner/phase2.ts +4 -3
  42. package/src/fixtures/travel-planner/phase3.ts +19 -42
  43. package/src/fixtures/travel-planner/phase4.ts +24 -37
  44. package/src/fixtures/travel-planner/phase5.ts +35 -42
  45. package/src/fixtures/travel-planner/phase6.ts +191 -88
  46. package/src/fixtures/travel-planner/phase7.ts +4 -102
  47. package/src/fixtures/travel-planner/scenarios.ts +3 -4
  48. package/src/fixtures/travel-planner/subagents-durable.ts +35 -61
  49. package/src/fixtures/travel-planner/subagents.ts +36 -14
  50. package/src/index.ts +1 -11
  51. package/src/internal/certification-report.ts +25 -0
  52. package/dist/index.mjs.map +0 -1
  53. package/src/code-executor-conformance.d.ts +0 -30
  54. package/src/fixtures/travel-planner/index.ts +0 -11
  55. package/src/fixtures/warehouse/index.ts +0 -412
@@ -1,24 +1,14 @@
1
- import {
2
- connectMcp,
3
- Redactor,
4
- SubagentReservationsMemoryLive,
5
- type McpDiscovery,
6
- type RedactedPreview,
7
- type RedactionError,
8
- } from "@effect-agent/capabilities";
9
- import { Agent, type ConversationId } from "@effect-agent/core";
10
- import {
11
- DefinitionDigests,
12
- DeploymentId,
13
- Digest,
14
- DurableWorkerBinding,
15
- Principal,
16
- ProducerId,
17
- type DurableSubmitOptions,
18
- type IdempotencyKey,
19
- type ResolvedBinding,
20
- } from "@effect-agent/session";
21
- import { Crypto, Effect, Layer, Ref, Schema, Stream } from "effect";
1
+ import type { Crypto } from "effect";
2
+ import { Effect, Layer, Ref, Schema, Stream } from "effect";
3
+ import * as Agent from "effect-agent/agent";
4
+ import { DurableWorkerBinding, type ResolvedBinding } from "effect-agent/agent-registration";
5
+ import { type DurableSubmitOptions } from "effect-agent/durable-agent-runtime";
6
+ import { type ThreadId } from "effect-agent/identifiers";
7
+ import { connectMcp, type McpDiscovery } from "effect-agent/mcp";
8
+ import { DefinitionDigests, DeploymentId, Digest, ProducerId } from "effect-agent/records";
9
+ import { Redactor, type RedactedPreview, type RedactionError } from "effect-agent/redaction";
10
+ import { SubagentReservationsMemoryLive } from "effect-agent/subagent-reservations";
11
+ import { Principal, type IdempotencyKey } from "effect-agent/submission-ledger";
22
12
  import { LanguageModel, Model, type Response } from "effect/unstable/ai";
23
13
 
24
14
  import { DeterministicIdGeneratorLayer } from "../travel-planner/deterministic-layers.ts";
@@ -52,9 +42,11 @@ import {
52
42
  export const docsResearcherDeploymentId = Schema.decodeSync(DeploymentId)(
53
43
  "docs-researcher-p7-deployment",
54
44
  );
45
+
55
46
  export const docsResearcherProducerId = Schema.decodeSync(ProducerId)(
56
47
  "docs-researcher-p7-producer",
57
48
  );
49
+
58
50
  export const docsResearcherPrincipal = Schema.decodeSync(Principal)("docs-researcher-p7-principal");
59
51
 
60
52
  const digestOf = (pair: string) => Schema.decodeSync(Digest)(pair.repeat(32));
@@ -75,10 +67,10 @@ export const docsSummarizerDigests = DefinitionDigests.make({
75
67
 
76
68
  /** Durable admission options for one docs-researcher Submission on one mission lane. */
77
69
  export const docsResearcherSubmitOptions = (
78
- conversationId: ConversationId,
70
+ threadId: ThreadId,
79
71
  idempotencyKey: IdempotencyKey,
80
72
  ): DurableSubmitOptions => ({
81
- conversationId,
73
+ threadId,
82
74
  principal: docsResearcherPrincipal,
83
75
  idempotencyKey,
84
76
  definitions: docsCoordinatorDigests,
@@ -100,15 +92,13 @@ const scriptedUsage = { inputTokens: { total: 96 }, outputTokens: { total: 64 }
100
92
  const summaryDelegationParts = (
101
93
  documentIds: ReadonlyArray<string>,
102
94
  ): ReadonlyArray<Response.StreamPartEncoded> => [
103
- ...documentIds.map(
104
- (documentId): Response.StreamPartEncoded => ({
105
- type: "tool-call",
106
- id: summarizeCallId(documentId),
107
- name: "delegate_document_summary",
108
- params: { documentId },
109
- providerExecuted: false,
110
- }),
111
- ),
95
+ ...documentIds.map((documentId): Response.StreamPartEncoded => ({
96
+ type: "tool-call",
97
+ id: summarizeCallId(documentId),
98
+ name: "delegate_document_summary",
99
+ params: { documentId },
100
+ providerExecuted: false,
101
+ })),
112
102
  { type: "finish", reason: "tool-calls", usage: scriptedUsage },
113
103
  ];
114
104
 
@@ -155,6 +145,7 @@ const makeCountingModel = (
155
145
  Effect.gen(function* () {
156
146
  const calls = yield* Ref.make(0);
157
147
  const prompts = yield* Ref.make<ReadonlyArray<string>>([]);
148
+
158
149
  const model = Model.make(
159
150
  "scripted",
160
151
  name,
@@ -167,13 +158,16 @@ const makeCountingModel = (
167
158
  Effect.gen(function* () {
168
159
  yield* Ref.update(calls, (value) => value + 1);
169
160
  const promptJson = JSON.stringify(request.prompt);
161
+
170
162
  yield* Ref.update(prompts, (previous) => [...previous, promptJson]);
163
+
171
164
  return Stream.fromIterable(yield* decide(promptJson));
172
165
  }),
173
166
  ),
174
167
  }),
175
168
  ),
176
169
  );
170
+
177
171
  return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };
178
172
  });
179
173
 
@@ -185,7 +179,7 @@ export interface DocsResearcherHarnessOptions {
185
179
 
186
180
  /** One durable coordinator/summarizer pair with observable counters and MCP evidence. */
187
181
  export interface DocsResearcherHarness {
188
- /** Host registrations for `NodeDurableRuntimeOptions.bindings` (parent + child). */
182
+ /** Resolved registrations for the parent and child workers. */
189
183
  readonly bindings: ReadonlyArray<ResolvedBinding>;
190
184
  /** The validated MCP discovery the child toolkit registration was gated on. */
191
185
  readonly discovery: McpDiscovery;
@@ -218,12 +212,15 @@ export const makeDocsResearcherHarness = (options?: DocsResearcherHarnessOptions
218
212
  const discovery = yield* Effect.scoped(
219
213
  Effect.gen(function* () {
220
214
  const connection = yield* connectMcp(docsMcpRequest);
215
+
221
216
  yield* assertDiscoveryMatchesAuthoredToolkit(connection);
217
+
222
218
  return connection.discovery;
223
219
  }),
224
220
  ).pipe(Effect.provide(docsMcpConnectorLayer));
225
221
 
226
222
  const fetchCounts = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
223
+
227
224
  const libraryLayer = Layer.succeed(
228
225
  DocumentLibrary,
229
226
  DocumentLibrary.of({
@@ -233,14 +230,17 @@ export const makeDocsResearcherHarness = (options?: DocsResearcherHarnessOptions
233
230
  ).pipe(Effect.andThen(researchDocumentLookup(query))),
234
231
  }),
235
232
  );
233
+
236
234
  const childToolkitLayer = docContentToolkitLayer.pipe(Layer.provideMerge(libraryLayer));
237
235
 
238
236
  const childModel = yield* makeCountingModel("doc-summarizer-p7", (promptJson) =>
239
237
  Effect.suspend(() => {
240
238
  const documentId = documentIds.find((candidate) => promptJson.includes(candidate));
239
+
241
240
  if (documentId === undefined) {
242
241
  return Effect.die(new Error("The summarizer prompt names no corpus document"));
243
242
  }
243
+
244
244
  return Effect.succeed(
245
245
  promptJson.includes(fetchCallId(documentId))
246
246
  ? summaryParts(documentId)
@@ -248,9 +248,11 @@ export const makeDocsResearcherHarness = (options?: DocsResearcherHarnessOptions
248
248
  );
249
249
  }),
250
250
  );
251
+
251
252
  const childBinding = Agent.withModel(DocSummarizer, childModel.model);
252
253
 
253
254
  const firstCallId = summarizeCallId(documentIds[0] ?? "durability-notes");
255
+
254
256
  const parentModel = yield* makeCountingModel("docs-researcher-p7", (promptJson) =>
255
257
  Effect.succeed(
256
258
  promptJson.includes(firstCallId)
@@ -258,6 +260,7 @@ export const makeDocsResearcherHarness = (options?: DocsResearcherHarnessOptions
258
260
  : summaryDelegationParts(documentIds),
259
261
  ),
260
262
  );
263
+
261
264
  const parentBinding = Agent.withModel(DocsResearcher, parentModel.model);
262
265
 
263
266
  const delegationLayer = docsSummaryHandlersLayer(childBinding).pipe(
@@ -274,6 +277,7 @@ export const makeDocsResearcherHarness = (options?: DocsResearcherHarnessOptions
274
277
  parentBinding,
275
278
  docsCoordinatorDigests,
276
279
  ).pipe(Effect.provide(delegationLayer));
280
+
277
281
  const childResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
278
282
  childBinding,
279
283
  docsSummarizerDigests,
@@ -289,6 +293,7 @@ export const makeDocsResearcherHarness = (options?: DocsResearcherHarnessOptions
289
293
  fetchInvocations: (documentId) =>
290
294
  Ref.get(fetchCounts).pipe(Effect.map((current) => current.get(documentId) ?? 0)),
291
295
  };
296
+
292
297
  return harness;
293
298
  });
294
299
 
@@ -297,15 +302,17 @@ const encodeResearchDocument = Schema.encodeEffect(ResearchDocument);
297
302
  /**
298
303
  * The audit-surface preview of one fetched document: the raw document —
299
304
  * secret marker and all — passes through the configured structural `Redactor`
300
- * before anything may quote it outside the child Conversation (SEC-008,
305
+ * before anything may quote it outside the child Thread (SEC-008,
301
306
  * CAP-013). Tests assert the preview keeps shape but no scalar content.
302
307
  */
303
308
  export const redactedDocumentPreview = Effect.fn("DocsResearcher.redactedDocumentPreview")(
304
309
  function* (documentId: string): Effect.fn.Return<RedactedPreview, RedactionError, Redactor> {
305
310
  const redactor = yield* Redactor;
311
+
306
312
  const encoded = yield* encodeResearchDocument(researchDocumentFor(documentId)).pipe(
307
313
  Effect.orDie,
308
314
  );
315
+
309
316
  return yield* redactor.redact(encoded);
310
317
  },
311
318
  );
@@ -1,11 +1,12 @@
1
+ import type { JsonSchema } from "effect";
2
+ import { Effect, JsonPointer, Layer, Schema } from "effect";
1
3
  import {
2
4
  McpConnectionRequest,
3
5
  McpConnector,
4
6
  McpServerIdentity,
5
7
  McpToolkitMismatch,
6
8
  type McpConnection,
7
- } from "@effect-agent/capabilities";
8
- import { Effect, Layer, Schema } from "effect";
9
+ } from "effect-agent/mcp";
9
10
  import { Tool } from "effect/unstable/ai";
10
11
  import * as McpSchema from "effect/unstable/ai/McpSchema";
11
12
 
@@ -37,10 +38,52 @@ export const docsMcpIdentity = McpServerIdentity.make({
37
38
  }),
38
39
  });
39
40
 
41
+ /**
42
+ * `Tool.getJsonSchema` produces a `$ref`/`$defs`-shaped schema for
43
+ * `FetchDocument`'s named, refined parameters type, but `McpSchema.Tool`'s
44
+ * `inputSchema` requires a flat `{ type: "object", ... }` root — the shape a
45
+ * real MCP server advertises on the wire. This inlines the single top-level
46
+ * `$ref` so the derivation described above still holds byte-for-byte.
47
+ */
48
+ const JsonSchemaDefinitions = Schema.Record(
49
+ Schema.String,
50
+ Schema.Record(Schema.String, Schema.Unknown),
51
+ );
52
+
53
+ const decodeJsonSchemaDefinitions = Schema.decodeUnknownSync(JsonSchemaDefinitions);
54
+ const decodeToolJsonSchema = Schema.decodeUnknownSync(McpSchema.ToolJsonSchema);
55
+
56
+ const flattenTopLevelRef = (schema: JsonSchema.JsonSchema): McpSchema.ToolJsonSchema => {
57
+ const ref = schema["$ref"];
58
+
59
+ if (typeof ref !== "string") {
60
+ return decodeToolJsonSchema(schema);
61
+ }
62
+
63
+ const defs = decodeJsonSchemaDefinitions(schema["$defs"]);
64
+
65
+ const key = ref.startsWith("#/$defs/")
66
+ ? JsonPointer.unescapeToken(ref.slice("#/$defs/".length))
67
+ : undefined;
68
+
69
+ const resolved = key !== undefined && Object.hasOwn(defs, key) ? defs[key] : undefined;
70
+
71
+ return decodeToolJsonSchema(resolved ?? schema);
72
+ };
73
+
74
+ const fetchDocumentOutputSchema = flattenTopLevelRef(
75
+ Tool.getJsonSchemaFromSchema(FetchDocument.successSchema),
76
+ );
77
+
40
78
  const discoveredFetchDocument = McpSchema.Tool.make({
41
79
  name: FetchDocument.name,
42
80
  description: "Fetch one bounded research document by its identifier.",
43
- inputSchema: Tool.getJsonSchema(FetchDocument),
81
+ inputSchema: flattenTopLevelRef(Tool.getJsonSchema(FetchDocument)),
82
+ // `validateMcpDiscovery` only compares an `outputSchema` derived down to an
83
+ // object type; mirror that so this fixture stays a real round-trip check.
84
+ ...(fetchDocumentOutputSchema.type === "object"
85
+ ? { outputSchema: fetchDocumentOutputSchema }
86
+ : {}),
44
87
  });
45
88
 
46
89
  const scriptedConnector = (tools: ReadonlyArray<McpSchema.Tool>): Layer.Layer<McpConnector> =>
@@ -98,9 +141,11 @@ export const assertDiscoveryMatchesAuthoredToolkit = Effect.fn(
98
141
  const authored = Object.values(DocContentToolkit.tools)
99
142
  .map((tool) => ({ name: tool.name, inputSchema: Tool.getJsonSchema(tool) }))
100
143
  .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
144
+
101
145
  const discovered = Object.values(connection.toolkit.tools)
102
146
  .map((tool) => ({ name: tool.name, inputSchema: Tool.getJsonSchema(tool) }))
103
147
  .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
148
+
104
149
  const matches =
105
150
  authored.length === discovered.length &&
106
151
  authored.every(
@@ -108,6 +153,7 @@ export const assertDiscoveryMatchesAuthoredToolkit = Effect.fn(
108
153
  tool.name === discovered[index]?.name &&
109
154
  isJsonEqual(tool.inputSchema, discovered[index]?.inputSchema),
110
155
  );
156
+
111
157
  if (!matches) {
112
158
  return yield* McpToolkitMismatch.make({
113
159
  serverId: connection.discovery.identity.serverId,
@@ -1,15 +1,18 @@
1
- import { Agent, AgentPolicy } from "@effect-agent/core";
2
1
  import { Context, Effect, Schema } from "effect";
2
+ import * as Agent from "effect-agent/agent";
3
+ import { AgentPolicy } from "effect-agent/agent-policy";
3
4
  import { Tool, Toolkit } from "effect/unstable/ai";
4
5
 
5
6
  export const AirportCode = Schema.NonEmptyString.pipe(
6
7
  Schema.brand("@effect-agent/testing/travel-planner/AirportCode"),
7
8
  );
9
+
8
10
  export type AirportCode = typeof AirportCode.Type;
9
11
 
10
12
  export const QuoteId = Schema.NonEmptyString.pipe(
11
13
  Schema.brand("@effect-agent/testing/travel-planner/QuoteId"),
12
14
  );
15
+
13
16
  export type QuoteId = typeof QuoteId.Type;
14
17
 
15
18
  export class TripRequest extends Schema.Class<TripRequest>("TripRequest")({
@@ -84,18 +87,22 @@ export class TravelPlan extends Schema.Class<TravelPlan>("TravelPlan")({
84
87
  }) {}
85
88
 
86
89
  const unavailableFields = { query: Schema.String, message: Schema.String };
90
+
87
91
  export class FlightUnavailable extends Schema.TaggedError<FlightUnavailable>()(
88
92
  "FlightUnavailable",
89
93
  unavailableFields,
90
94
  ) {}
95
+
91
96
  export class LodgingUnavailable extends Schema.TaggedError<LodgingUnavailable>()(
92
97
  "LodgingUnavailable",
93
98
  unavailableFields,
94
99
  ) {}
100
+
95
101
  export class ActivityUnavailable extends Schema.TaggedError<ActivityUnavailable>()(
96
102
  "ActivityUnavailable",
97
103
  unavailableFields,
98
104
  ) {}
105
+
99
106
  export class GuidanceFailure extends Schema.TaggedError<GuidanceFailure>()("GuidanceFailure", {
100
107
  message: Schema.String,
101
108
  }) {}
@@ -104,10 +111,12 @@ export class FlightCatalog extends Context.Service<
104
111
  FlightCatalog,
105
112
  { readonly search: (query: FlightQuery) => Effect.Effect<FlightOption, FlightUnavailable> }
106
113
  >()("@effect-agent/testing/travel-planner/FlightCatalog") {}
114
+
107
115
  export class LodgingCatalog extends Context.Service<
108
116
  LodgingCatalog,
109
117
  { readonly search: (query: LodgingQuery) => Effect.Effect<LodgingOption, LodgingUnavailable> }
110
118
  >()("@effect-agent/testing/travel-planner/LodgingCatalog") {}
119
+
111
120
  export class ActivityCatalog extends Context.Service<
112
121
  ActivityCatalog,
113
122
  {
@@ -116,6 +125,7 @@ export class ActivityCatalog extends Context.Service<
116
125
  ) => Effect.Effect<ActivitySearchResult, ActivityUnavailable>;
117
126
  }
118
127
  >()("@effect-agent/testing/travel-planner/ActivityCatalog") {}
128
+
119
129
  export class TravelGuidance extends Context.Service<
120
130
  TravelGuidance,
121
131
  { readonly instructions: (input: TripRequest) => Effect.Effect<string, GuidanceFailure> }
@@ -128,6 +138,7 @@ export const SearchFlights = Tool.make("search_flights", {
128
138
  failureMode: "error",
129
139
  dependencies: [FlightCatalog],
130
140
  });
141
+
131
142
  export const SearchLodging = Tool.make("search_lodging", {
132
143
  parameters: LodgingQuery,
133
144
  success: LodgingOption,
@@ -135,6 +146,7 @@ export const SearchLodging = Tool.make("search_lodging", {
135
146
  failureMode: "error",
136
147
  dependencies: [LodgingCatalog],
137
148
  });
149
+
138
150
  export const SearchActivities = Tool.make("search_activities", {
139
151
  parameters: ActivityQuery,
140
152
  success: ActivitySearchResult,
@@ -144,13 +156,14 @@ export const SearchActivities = Tool.make("search_activities", {
144
156
  });
145
157
 
146
158
  export const TravelPlannerToolkit = Toolkit.make(SearchFlights, SearchLodging, SearchActivities);
159
+
147
160
  export const TravelPlannerToolkitLayer = TravelPlannerToolkit.toLayer({
148
161
  search_flights: (query) => Effect.flatMap(FlightCatalog, (catalog) => catalog.search(query)),
149
162
  search_lodging: (query) => Effect.flatMap(LodgingCatalog, (catalog) => catalog.search(query)),
150
163
  search_activities: (query) => Effect.flatMap(ActivityCatalog, (catalog) => catalog.search(query)),
151
164
  });
152
165
 
153
- export const TravelPlanner = Agent.define("travel-planner", {
166
+ export const TravelPlanner = Agent.make("travel-planner", {
154
167
  input: TripRequest,
155
168
  output: TravelPlan,
156
169
  instructions: (input) =>
@@ -1,5 +1,8 @@
1
- import { ConversationId, IdGenerator, RunId, TurnId } from "@effect-agent/core";
2
1
  import { Context, Deferred, Effect, Layer, Option, Ref, Schema } from "effect";
2
+ import { IdGenerator } from "effect-agent/id-generator";
3
+ import { ThreadId, RunId, TurnId } from "effect-agent/identifiers";
4
+ import { RunContextPreparationPassthrough } from "effect-agent/run-options";
5
+ import { ThreadHistory } from "effect-agent/thread-history";
3
6
 
4
7
  import {
5
8
  ActivityCatalog,
@@ -23,6 +26,7 @@ export class CatalogLifecycleCounts extends Schema.Class<CatalogLifecycleCounts>
23
26
  acquired: Schema.Natural,
24
27
  finalized: Schema.Natural,
25
28
  }) {}
29
+
26
30
  export class CatalogLifecycle extends Context.Service<
27
31
  CatalogLifecycle,
28
32
  {
@@ -36,6 +40,7 @@ export class CatalogLifecycle extends Context.Service<
36
40
  Effect.gen(function* () {
37
41
  const acquired = yield* Ref.make(0);
38
42
  const finalized = yield* Ref.make(0);
43
+
39
44
  return CatalogLifecycle.of({
40
45
  markAcquired: Ref.update(acquired, (n) => n + 1),
41
46
  markFinalized: Ref.update(finalized, (n) => n + 1),
@@ -53,11 +58,13 @@ const flight = FlightOption.make({
53
58
  estimatedCents: 180_000,
54
59
  currency: "USD",
55
60
  });
61
+
56
62
  const lodging = LodgingOption.make({
57
63
  lodging: "Bloomsbury House · refundable studio · 4 nights",
58
64
  estimatedCents: 104_000,
59
65
  currency: "USD",
60
66
  });
67
+
61
68
  const activities = ActivitySearchResult.make({
62
69
  activities: ["British Museum timed entry", "Thames evening walk"],
63
70
  });
@@ -84,6 +91,7 @@ export const ReverseCompletionToolkitLayer = Effect.gen(function* () {
84
91
  const releaseFlight = yield* Deferred.make<void>();
85
92
  const releaseLodging = yield* Deferred.make<void>();
86
93
  const releaseActivity = yield* Deferred.make<void>();
94
+
87
95
  const awaitRelease = <A>(
88
96
  started: Deferred.Deferred<void>,
89
97
  release: Deferred.Deferred<void>,
@@ -93,6 +101,7 @@ export const ReverseCompletionToolkitLayer = Effect.gen(function* () {
93
101
  Effect.andThen(Deferred.await(release)),
94
102
  Effect.as(value),
95
103
  );
104
+
96
105
  return {
97
106
  controls: {
98
107
  flightStarted: Deferred.await(flightStarted),
@@ -114,7 +123,9 @@ export const FlightCatalogLayer = Layer.effect(
114
123
  FlightCatalog,
115
124
  Effect.gen(function* () {
116
125
  const lifecycle = yield* CatalogLifecycle;
126
+
117
127
  yield* Effect.acquireRelease(lifecycle.markAcquired, () => lifecycle.markFinalized);
128
+
118
129
  return FlightCatalog.of({
119
130
  search: (query) =>
120
131
  query.origin === query.destination
@@ -128,11 +139,14 @@ export const FlightCatalogLayer = Layer.effect(
128
139
  });
129
140
  }),
130
141
  );
142
+
131
143
  export const LodgingCatalogLayer = Layer.effect(
132
144
  LodgingCatalog,
133
145
  Effect.gen(function* () {
134
146
  const lifecycle = yield* CatalogLifecycle;
147
+
135
148
  yield* Effect.acquireRelease(lifecycle.markAcquired, () => lifecycle.markFinalized);
149
+
136
150
  return LodgingCatalog.of({
137
151
  search: (query) =>
138
152
  query.nights < 1
@@ -146,11 +160,14 @@ export const LodgingCatalogLayer = Layer.effect(
146
160
  });
147
161
  }),
148
162
  );
163
+
149
164
  export const ActivityCatalogLayer = Layer.effect(
150
165
  ActivityCatalog,
151
166
  Effect.gen(function* () {
152
167
  const lifecycle = yield* CatalogLifecycle;
168
+
153
169
  yield* Effect.acquireRelease(lifecycle.markAcquired, () => lifecycle.markFinalized);
170
+
154
171
  return ActivityCatalog.of({
155
172
  search: (query) =>
156
173
  query.destination === ""
@@ -164,10 +181,12 @@ export const ActivityCatalogLayer = Layer.effect(
164
181
  });
165
182
  }),
166
183
  );
184
+
167
185
  /** Stable supplier-side booking identity, minted deterministically from the idempotency key. */
168
186
  export const BookingRef = Schema.NonEmptyString.pipe(
169
187
  Schema.brand("@effect-agent/testing/travel-planner/BookingRef"),
170
188
  );
189
+
171
190
  export type BookingRef = typeof BookingRef.Type;
172
191
 
173
192
  /** The supplier desk operations the P5 booking Tools and Steps invoke. */
@@ -178,6 +197,7 @@ export const SupplierOperation = Schema.Literals([
178
197
  "reserve-lodging",
179
198
  "issue-confirmation",
180
199
  ]);
200
+
181
201
  export type SupplierOperation = typeof SupplierOperation.Type;
182
202
 
183
203
  /**
@@ -286,7 +306,9 @@ export class SupplierBookingDesk extends Context.Service<
286
306
  request.idempotencyKey,
287
307
  (current.counts.get(request.idempotencyKey) ?? 0) + 1,
288
308
  );
309
+
289
310
  const existing = current.bookings.get(request.idempotencyKey);
311
+
290
312
  const record =
291
313
  existing ??
292
314
  SupplierBookingRecord.make({
@@ -296,18 +318,24 @@ export class SupplierBookingDesk extends Context.Service<
296
318
  detail: request.detail,
297
319
  status: "confirmed",
298
320
  });
321
+
299
322
  const bookings =
300
323
  existing === undefined
301
324
  ? new Map(current.bookings).set(request.idempotencyKey, record)
302
325
  : current.bookings;
326
+
303
327
  const hold = Option.fromNullishOr(current.holds.get(request.idempotencyKey));
328
+
304
329
  const holds = Option.isSome(hold)
305
330
  ? (() => {
306
331
  const next = new Map(current.holds);
332
+
307
333
  next.delete(request.idempotencyKey);
334
+
308
335
  return next;
309
336
  })()
310
337
  : current.holds;
338
+
311
339
  return [
312
340
  { record, hold },
313
341
  { bookings, counts, holds },
@@ -318,9 +346,11 @@ export class SupplierBookingDesk extends Context.Service<
318
346
  Ref.modify(state, (current) => {
319
347
  const key = cancelBookingIdempotencyKey(bookingRef);
320
348
  const counts = new Map(current.counts).set(key, (current.counts.get(key) ?? 0) + 1);
349
+
321
350
  const existingEntry = [...current.bookings.entries()].find(
322
351
  ([, record]) => record.bookingRef === bookingRef,
323
352
  );
353
+
324
354
  if (existingEntry === undefined) {
325
355
  return [
326
356
  { record: Option.none<SupplierBookingRecord>(), hold: Option.none() },
@@ -328,19 +358,25 @@ export class SupplierBookingDesk extends Context.Service<
328
358
  ] as const;
329
359
  }
330
360
  const [storeKey, existing] = existingEntry;
361
+
331
362
  const cancelled =
332
363
  existing.status === "cancelled"
333
364
  ? existing
334
365
  : SupplierBookingRecord.make({ ...existing, status: "cancelled" });
366
+
335
367
  const bookings = new Map(current.bookings).set(storeKey, cancelled);
336
368
  const hold = Option.fromNullishOr(current.holds.get(key));
369
+
337
370
  const holds = Option.isSome(hold)
338
371
  ? (() => {
339
372
  const next = new Map(current.holds);
373
+
340
374
  next.delete(key);
375
+
341
376
  return next;
342
377
  })()
343
378
  : current.holds;
379
+
344
380
  return [
345
381
  { record: Option.some(cancelled), hold },
346
382
  { bookings, counts, holds },
@@ -371,10 +407,12 @@ export class SupplierBookingDesk extends Context.Service<
371
407
  Effect.gen(function* () {
372
408
  const held = yield* Deferred.make<void>();
373
409
  const release = yield* Deferred.make<void>();
410
+
374
411
  yield* Ref.update(state, (current) => ({
375
412
  ...current,
376
413
  holds: new Map(current.holds).set(idempotencyKey, { held, release }),
377
414
  }));
415
+
378
416
  return {
379
417
  held: Deferred.await(held),
380
418
  release: Deferred.succeed(release, undefined).pipe(Effect.asVoid),
@@ -402,15 +440,17 @@ export const TravelGuidanceLayer = Layer.succeed(
402
440
  ),
403
441
  }),
404
442
  );
443
+
405
444
  export const DeterministicIdGeneratorLayer = Layer.effect(
406
445
  IdGenerator,
407
446
  Effect.gen(function* () {
408
- const conversation = yield* Ref.make(0);
447
+ const thread = yield* Ref.make(0);
409
448
  const run = yield* Ref.make(0);
410
449
  const turn = yield* Ref.make(0);
450
+
411
451
  return IdGenerator.of({
412
- nextConversationId: Ref.updateAndGet(conversation, (n) => n + 1).pipe(
413
- Effect.map((n) => Schema.decodeSync(ConversationId)(`conversation-${n}`)),
452
+ nextThreadId: Ref.updateAndGet(thread, (n) => n + 1).pipe(
453
+ Effect.map((n) => Schema.decodeSync(ThreadId)(`thread-${n}`)),
414
454
  ),
415
455
  nextRunId: Ref.updateAndGet(run, (n) => n + 1).pipe(
416
456
  Effect.map((n) => Schema.decodeSync(RunId)(`run-${n}`)),
@@ -421,7 +461,10 @@ export const DeterministicIdGeneratorLayer = Layer.effect(
421
461
  });
422
462
  }),
423
463
  );
464
+
424
465
  export const TravelPlannerRuntimeLayer = Layer.mergeAll(
466
+ RunContextPreparationPassthrough,
467
+ ThreadHistory.layer,
425
468
  TravelPlannerToolkitLayer,
426
469
  FlightCatalogLayer,
427
470
  LodgingCatalogLayer,
@@ -1,5 +1,6 @@
1
- import { Agent, AgentPolicy } from "@effect-agent/core";
2
1
  import { Context, Effect, Schema } from "effect";
2
+ import * as Agent from "effect-agent/agent";
3
+ import { AgentPolicy } from "effect-agent/agent-policy";
3
4
  import { Tool, Toolkit } from "effect/unstable/ai";
4
5
 
5
6
  import {
@@ -75,7 +76,7 @@ export const TravelPlannerPhase2ToolkitLayer = TravelPlannerPhase2Toolkit.toLaye
75
76
  Effect.flatMap(ItineraryHoldGateway, (gateway) => gateway.hold(request)),
76
77
  });
77
78
 
78
- export const TravelPlannerPhase2 = Agent.define("travel-planner-phase-2", {
79
+ export const TravelPlannerPhase2 = Agent.make("travel-planner-phase-2", {
79
80
  input: TripRequest,
80
81
  output: TravelPlan,
81
82
  instructions: (input) =>
@@ -86,7 +87,7 @@ export const TravelPlannerPhase2 = Agent.define("travel-planner-phase-2", {
86
87
  maxToolCalls: 4,
87
88
  maxDuration: "30 seconds",
88
89
  toolConcurrency: 3,
89
- tokenBudget: 2_048,
90
+ tokenBudget: 4_096,
90
91
  }),
91
92
  description:
92
93
  "Build a review-only itinerary and require approval before creating a temporary hold.",