@popcomputer/structured-chat 0.1.0 → 0.2.0-rc.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.
Files changed (41) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +122 -108
  3. package/dist/adapters/openai-compatible-model.d.ts +4 -4
  4. package/dist/adapters/openai-compatible-model.js +33 -31
  5. package/dist/core/answer.d.ts +13 -13
  6. package/dist/core/answer.js +21 -12
  7. package/dist/core/chat.d.ts +12 -15
  8. package/dist/core/chat.js +36 -27
  9. package/dist/core/collect-stage.d.ts +11 -14
  10. package/dist/core/collect-stage.js +40 -37
  11. package/dist/core/command.d.ts +1 -1
  12. package/dist/core/command.js +1 -1
  13. package/dist/core/json-value.d.ts +1 -1
  14. package/dist/core/json-value.js +8 -1
  15. package/dist/core/model-guard.d.ts +2 -3
  16. package/dist/core/model-guard.js +4 -4
  17. package/dist/core/model.d.ts +15 -19
  18. package/dist/core/model.js +22 -10
  19. package/dist/core/protocol.d.ts +84 -79
  20. package/dist/core/protocol.js +18 -12
  21. package/dist/core/question.js +17 -15
  22. package/dist/core/repair.js +1 -1
  23. package/dist/core/session.d.ts +22 -28
  24. package/dist/core/session.js +16 -7
  25. package/dist/core/stage-name.d.ts +1 -1
  26. package/dist/core/stage-name.js +1 -1
  27. package/dist/core/stage.d.ts +4 -4
  28. package/dist/core/stage.js +11 -8
  29. package/dist/core/tool-set.js +6 -6
  30. package/dist/core/tool.d.ts +36 -39
  31. package/dist/core/tool.js +58 -31
  32. package/dist/core/view.d.ts +64 -39
  33. package/dist/core/view.js +12 -15
  34. package/dist/integrations/assistant-ui.js +21 -14
  35. package/dist/testing/in-memory-session-store.js +1 -1
  36. package/dist/testing/scenario.js +6 -6
  37. package/examples/answer-modes.ts +60 -49
  38. package/examples/prompt-injection-policy.ts +20 -20
  39. package/examples/resource-search.ts +104 -0
  40. package/package.json +11 -3
  41. package/examples/agency-search.ts +0 -101
package/LICENSE CHANGED
@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
-
package/README.md CHANGED
@@ -14,7 +14,7 @@ The framework derives the model tool schemas, runtime validation, workflow
14
14
  state, browser protocol, and Effect requirements from those definitions.
15
15
 
16
16
  ```sh
17
- bun add @popcomputer/structured-chat effect
17
+ bun add @popcomputer/structured-chat@next effect@^4.0.0-rc.109
18
18
  ```
19
19
 
20
20
  The published entry points are ESM-only and support Node.js 22 or newer.
@@ -28,40 +28,42 @@ import {
28
28
  Stage,
29
29
  Tool,
30
30
  } from "@popcomputer/structured-chat"
31
- import { Effect, Schema } from "effect"
31
+ import { Schema } from "effect"
32
32
 
33
- const AgencyCards = defineView({
34
- name: "agency_cards",
33
+ const ResultCards = defineView({
34
+ name: "result_cards",
35
35
  version: 1,
36
36
  schema: Schema.Struct({
37
- agencies: Schema.Array(
37
+ results: Schema.Array(
38
38
  Schema.Struct({
39
39
  id: Schema.String,
40
- name: Schema.String,
41
- reason: Schema.String,
40
+ title: Schema.String,
41
+ summary: Schema.String,
42
42
  }),
43
43
  ),
44
44
  }),
45
45
  })
46
46
 
47
- const SearchAgencies = defineTool({
48
- name: "search_agencies",
49
- description: "Find agencies relevant to the completed project brief.",
50
- input: Schema.Struct({ query: Schema.NonEmptyTrimmedString }),
51
- execute: ({ query }) => AgencySearch.find(query),
47
+ const FindResources = defineTool({
48
+ name: "find_resources",
49
+ description: "Find resources relevant to the completed request.",
50
+ input: Schema.Struct({
51
+ query: Schema.Trimmed.check(Schema.isNonEmpty()),
52
+ }),
53
+ execute: ({ query }) => ResourceCatalog.search(query),
52
54
  }).pipe(
53
- Tool.modelResult(AgencyEvidenceSchema, ({ evidence }) => evidence),
54
- Tool.present(AgencyCards, ({ agencies }) => ({ agencies })),
55
+ Tool.modelResult(ResourceEvidenceSchema, ({ evidence }) => evidence),
56
+ Tool.present(ResultCards, ({ results }) => ({ results })),
55
57
  )
56
58
 
57
- const Matching = Stage.tools({
58
- name: "matching",
59
- instructions: ["Route the completed brief to one agency search."],
60
- tools: [SearchAgencies],
59
+ const Lookup = Stage.tools({
60
+ name: "lookup",
61
+ instructions: ["Route the completed request to one resource lookup."],
62
+ tools: [FindResources],
61
63
  })
62
64
  ```
63
65
 
64
- `SearchAgencies` is the single source of truth for one capability. Its input
66
+ `FindResources` is the single source of truth for one capability. Its input
65
67
  schema becomes both the model-facing JSON Schema and the authoritative runtime
66
68
  parser. Its Effect error and service requirements remain typed.
67
69
 
@@ -76,6 +78,10 @@ The three result surfaces are deliberately separate:
76
78
  Internal data does not reach the model or browser merely because a tool loaded
77
79
  it.
78
80
 
81
+ See the compile-checked
82
+ [`resource-search.ts`](./examples/resource-search.ts) example for a complete
83
+ tool, service, model projection, browser projection, and stage definition.
84
+
79
85
  ## Compose directly with document-graph
80
86
 
81
87
  Structured chat does not wrap or replace retrieval. An application-defined
@@ -83,24 +89,26 @@ Structured chat does not wrap or replace retrieval. An application-defined
83
89
  execution directly:
84
90
 
85
91
  ```ts
86
- import { FindAgencies } from "./agency-graph.js"
92
+ import { SearchKnowledgeBase } from "./knowledge-graph.js"
87
93
 
88
- const SearchAgencies = defineTool({
89
- name: "search_agencies",
90
- description: "Find agencies supported by relevant work evidence.",
91
- input: Schema.Struct({ query: Schema.NonEmptyTrimmedString }),
92
- execute: ({ query }) => FindAgencies.search(query, { limit: 6 }),
94
+ const FindResources = defineTool({
95
+ name: "find_resources",
96
+ description: "Find resources supported by relevant source evidence.",
97
+ input: Schema.Struct({
98
+ query: Schema.Trimmed.check(Schema.isNonEmpty()),
99
+ }),
100
+ execute: ({ query }) => SearchKnowledgeBase.search(query, { limit: 6 }),
93
101
  }).pipe(
94
- Tool.modelResult(AgencyEvidenceSchema, toModelEvidence),
95
- Tool.present(AgencyCards, toAgencyCards),
102
+ Tool.modelResult(ResourceEvidenceSchema, toModelEvidence),
103
+ Tool.present(ResultCards, toResultCards),
96
104
  )
97
105
  ```
98
106
 
99
- `FindAgencies` remains the application-owned graph retrieval policy: it can
100
- combine direct Agency profile matches with Work evidence reached through a
101
- typed relationship. Structured chat contributes the closed tool schema,
102
- stage policy, result projections, and UI protocol. The graph's typed failures
103
- and Effect requirements flow through the tool without another adapter layer.
107
+ `SearchKnowledgeBase` remains the application-owned graph retrieval policy: it
108
+ can combine direct resource matches with supporting documents reached through
109
+ typed relationships. Structured chat contributes the closed tool schema, stage
110
+ policy, result projections, and UI protocol. The graph's typed failures and
111
+ Effect requirements flow through the tool without another adapter layer.
104
112
 
105
113
  ## Put a free-form conversation on rails
106
114
 
@@ -115,41 +123,40 @@ import {
115
123
  } from "@popcomputer/structured-chat"
116
124
  import { Schema } from "effect"
117
125
 
118
- const ProjectBrief = Stage.collect({
119
- name: "project_brief",
126
+ const RequestDetails = Stage.collect({
127
+ name: "request_details",
120
128
  questions: {
121
129
  guidance:
122
- "Ask one conversational question at a time and briefly explain why the answer improves the match.",
130
+ "Ask one conversational question at a time and briefly explain why the answer improves the result.",
123
131
  escape: "Not sure yet",
124
132
  },
125
133
  fields: {
126
- priority: Answer.semantic(Schema.NonEmptyTrimmedString, {
127
- description:
128
- "The unmet need the client most wants an agency to solve.",
134
+ goal: Answer.semantic(Schema.Trimmed.check(Schema.isNonEmpty()), {
135
+ description: "The outcome the user wants to achieve.",
129
136
  ask: Question.adaptiveChoice(
130
- "Where would outside agency expertise help most?",
137
+ "What would you like help accomplishing?",
131
138
  {
132
139
  minimumOptions: 3,
133
140
  maximumOptions: 5,
134
141
  fallbackOptions: [
135
- "Launch or grow a product",
136
- "Build the brand long term",
137
- "Fix a performance problem",
142
+ "Understand a topic",
143
+ "Compare available options",
144
+ "Plan the next steps",
138
145
  ],
139
146
  },
140
147
  ),
141
148
  }),
142
- location: Answer.explicit(Schema.NonEmptyTrimmedString, {
143
- description: "Where the client is based.",
144
- ask: Question.fixed("Where are you based?"),
149
+ audience: Answer.explicit(Schema.Trimmed.check(Schema.isNonEmpty()), {
150
+ description: "Who the requested result is for.",
151
+ ask: Question.fixed("Who is this for?"),
145
152
  }),
146
153
  },
147
154
  })
148
155
 
149
- const Matchmaker = defineChat({
150
- name: "agency_matchmaker",
156
+ const ResourceFinder = defineChat({
157
+ name: "resource_finder",
151
158
  version: 1,
152
- stages: [ProjectBrief, Matching],
159
+ stages: [RequestDetails, Lookup],
153
160
  })
154
161
  ```
155
162
 
@@ -170,7 +177,7 @@ while the field is pending. The value is validated against the answer schema
170
177
  at definition time and requires `questions.escape` to be configured; a
171
178
  confirmed field still requires its question to have been issued first.
172
179
  Fields without an escape value stay unresolved and are re-asked from another
173
- angle, so a client who genuinely cannot answer never blocks the workflow
180
+ angle, so a user who genuinely cannot answer never blocks the workflow
174
181
  unless the application wants it to.
175
182
 
176
183
  For an adaptive choice, valid contextual model suggestions take precedence.
@@ -214,15 +221,15 @@ Applications can read the typed value and its provenance without reaching into
214
221
  the persisted state shape:
215
222
 
216
223
  ```ts
217
- const priority = Matchmaker.getAcceptedAnswer(
224
+ const goal = ResourceFinder.getAcceptedAnswer(
218
225
  reply.turn.state,
219
- ProjectBrief,
220
- "priority",
226
+ RequestDetails,
227
+ "goal",
221
228
  )
222
229
 
223
- priority?.value
224
- priority?.evidence.messageIndex
225
- priority?.evidence.quote
230
+ goal?.value
231
+ goal?.evidence.messageIndex
232
+ goal?.evidence.quote
226
233
  ```
227
234
 
228
235
  For semantic answers, the quote supports the model's inference; it does not
@@ -234,23 +241,23 @@ Keep parsing and business acceptance separate when a structurally valid value
234
241
  may still be unsuitable for the workflow:
235
242
 
236
243
  ```ts
237
- class BudgetTooLow extends Schema.TaggedError<BudgetTooLow>()(
238
- "BudgetTooLow",
239
- { minimum: Schema.Number },
244
+ class ResultLimitOutOfRange extends Schema.TaggedError<ResultLimitOutOfRange>()(
245
+ "ResultLimitOutOfRange",
246
+ { minimum: Schema.Number, maximum: Schema.Number },
240
247
  ) {}
241
248
 
242
- const Budget = Answer.explicit(Schema.Number, {
249
+ const ResultLimit = Answer.explicit(Schema.Number, {
243
250
  // The description remains model-visible, so repeat acceptance constraints.
244
- description: "Project budget in GBP; must be at least 5,000",
245
- ask: Question.fixed("What budget have you set aside?"),
246
- validate: (budget) =>
247
- budget >= 5_000
251
+ description: "Number of results; must be a whole number from 1 to 20",
252
+ ask: Question.fixed("How many results would you like?"),
253
+ validate: (limit) =>
254
+ Number.isInteger(limit) && limit >= 1 && limit <= 20
248
255
  ? Effect.void
249
- : Effect.fail(new BudgetTooLow({ minimum: 5_000 })),
256
+ : Effect.fail(
257
+ new ResultLimitOutOfRange({ minimum: 1, maximum: 20 }),
258
+ ),
250
259
  reject: {
251
- ask: Question.fixed(
252
- "Our minimum engagement is £5,000. Could you revise the budget?",
253
- ),
260
+ ask: Question.fixed("Choose a whole number from 1 to 20."),
254
261
  },
255
262
  })
256
263
  ```
@@ -313,8 +320,8 @@ const program = Effect.gen(function* () {
313
320
  const request = yield* parseRequest(StructuredChatTurnRequestSchema)
314
321
  const publicSessionId = request.session?.id ?? crypto.randomUUID()
315
322
 
316
- const reply = yield* Matchmaker.reply({
317
- namespace: authenticatedUser.id,
323
+ const reply = yield* ResourceFinder.reply({
324
+ namespace: authenticatedActor.id,
318
325
  sessionId: publicSessionId,
319
326
  expectedRevision: request.session?.revision,
320
327
  message: request.message,
@@ -324,7 +331,7 @@ const program = Effect.gen(function* () {
324
331
  { ...reply, sessionId: publicSessionId },
325
332
  {
326
333
  result: ({ result }) => [
327
- Text.make("These are the strongest evidence-backed matches."),
334
+ Text.make("Here are the most relevant evidence-backed resources."),
328
335
  ...result.views,
329
336
  ],
330
337
  },
@@ -357,13 +364,13 @@ meaning.
357
364
  ## Continue naturally after the first tool result
358
365
 
359
366
  `Stage.tools(...)` stays active by default. A user can refine the previous
360
- search without restarting the collection stage:
367
+ lookup without restarting the collection stage:
361
368
 
362
369
  ```txt
363
- User: We need a public-sector service redesign.
364
- Assistant: [search results]
365
- User: Favour agencies with accessibility experience.
366
- Assistant: [refined search results]
370
+ User: We need onboarding guidance for a new team.
371
+ Assistant: [resource results]
372
+ User: Prefer concise resources with practical examples.
373
+ Assistant: [refined resource results]
367
374
  ```
368
375
 
369
376
  When a tool defines `Tool.modelResult(...)`, each bounded result is retained in
@@ -389,20 +396,20 @@ Declare side effects honestly. A command receives the package-derived stable
389
396
  ID that its application endpoint must use as an idempotency key:
390
397
 
391
398
  ```ts
392
- const Submit = defineCommand({
393
- name: "submit_application",
394
- description: "Submit the confirmed application once.",
395
- input: SubmitApplicationInput,
399
+ const CreateRequest = defineCommand({
400
+ name: "create_request",
401
+ description: "Create the confirmed request once.",
402
+ input: CreateRequestInput,
396
403
  execute: (input, { commandId }) =>
397
- Applications.submit(input, { idempotencyKey: commandId }),
404
+ Requests.create(input, { idempotencyKey: commandId }),
398
405
  }).pipe(
399
- Tool.present(SubmissionReceipt, toSubmissionReceipt),
406
+ Tool.present(RequestReceipt, toRequestReceipt),
400
407
  )
401
408
 
402
- const SubmitApplication = Stage.command({
403
- name: "submit_application",
404
- instructions: ["Submit the confirmed application."],
405
- command: Submit,
409
+ const CreateRequestStage = Stage.command({
410
+ name: "create_request",
411
+ instructions: ["Create the confirmed request."],
412
+ command: CreateRequest,
406
413
  })
407
414
  ```
408
415
 
@@ -431,10 +438,10 @@ Repair is disabled by default. Enable it only on chats whose final stage is a
431
438
  repeatable query:
432
439
 
433
440
  ```ts
434
- const Matchmaker = defineChat({
435
- name: "agency_matchmaker",
441
+ const RepairableResourceFinder = defineChat({
442
+ name: "resource_finder",
436
443
  version: 2,
437
- stages: [ProjectBrief, Matching],
444
+ stages: [RequestDetails, Lookup],
438
445
  repair: Repair.standard({ maximumCorrections: 5 }),
439
446
  })
440
447
  ```
@@ -550,21 +557,21 @@ import {
550
557
  } from "@popcomputer/structured-chat/assistant-ui"
551
558
  import type { ReactNode } from "react"
552
559
 
553
- const AgencyCardsUI = makeAssistantView(AgencyCards, {
554
- render: AgencyCardsComponent,
560
+ const ResultCardsUI = makeAssistantView(ResultCards, {
561
+ render: ResultCardsComponent,
555
562
  fallback: InvalidCardFallback,
556
563
  })
557
564
 
558
565
  const QuestionUI = makeAssistantView(CollectQuestionView, {
559
- render: ProjectQuestion,
566
+ render: RequestQuestion,
560
567
  fallback: InvalidQuestionFallback,
561
568
  })
562
569
 
563
570
  const model = makeAssistantChatModelAdapter({
564
- endpoint: "/api/matchmaker/turn",
571
+ endpoint: "/api/resource-finder/turn",
565
572
  })
566
573
 
567
- export function MatchmakerRuntime({
574
+ export function ResourceFinderRuntime({
568
575
  children,
569
576
  }: Readonly<{ children: ReactNode }>) {
570
577
  const runtime = useLocalRuntime(model)
@@ -572,7 +579,7 @@ export function MatchmakerRuntime({
572
579
  return (
573
580
  <AssistantRuntimeProvider runtime={runtime}>
574
581
  <QuestionUI />
575
- <AgencyCardsUI />
582
+ <ResultCardsUI />
576
583
  {children}
577
584
  </AssistantRuntimeProvider>
578
585
  )
@@ -598,16 +605,16 @@ history as authoritative.
598
605
  The default remains one call:
599
606
 
600
607
  ```ts
601
- const result = yield* Matching.run(messages)
608
+ const result = yield* Lookup.run(messages)
602
609
  ```
603
610
 
604
611
  Approval flows, dry runs, and evals can stop after a strictly parsed proposal:
605
612
 
606
613
  ```ts
607
- const call = yield* Matching.plan(messages)
614
+ const call = yield* Lookup.plan(messages)
608
615
 
609
616
  // Application-owned review or approval.
610
- const result = yield* Matching.toolSet.execute(call)
617
+ const result = yield* Lookup.toolSet.execute(call)
611
618
  ```
612
619
 
613
620
  `plan` uses the same trusted instructions, closed tool set, guards, provider
@@ -627,15 +634,22 @@ import {
627
634
  } from "@popcomputer/structured-chat/testing"
628
635
 
629
636
  const ModelTest = Scenario.model(
630
- Scenario.answers(ProjectBrief, {
631
- location: Scenario.quoted("Leeds", { quote: "based in Leeds" }),
637
+ Scenario.answers(RequestDetails, {
638
+ goal: Scenario.quoted("prepare an onboarding plan", {
639
+ quote: "prepare an onboarding plan",
640
+ }),
641
+ audience: Scenario.quoted("new team members", {
642
+ quote: "new team members",
643
+ }),
644
+ }),
645
+ Scenario.call(FindResources, {
646
+ query: "onboarding plan for new team members",
632
647
  }),
633
- Scenario.call(SearchAgencies, { query: "Leeds public services" }),
634
648
  )
635
649
 
636
- const reply = yield* Matchmaker.reply({
650
+ const reply = yield* ResourceFinder.reply({
637
651
  sessionId: "scenario-1",
638
- message: "We are based in Leeds.",
652
+ message: "Help me prepare an onboarding plan for new team members.",
639
653
  }).pipe(
640
654
  Effect.provide(Layer.merge(ModelTest, inMemoryChatSessionStore)),
641
655
  )
@@ -657,8 +671,8 @@ rules:
657
671
 
658
672
  ```ts
659
673
  Scenario.repairs(
660
- Scenario.replace(ProjectBrief, "location", "Manchester", {
661
- quote: "Actually, Manchester",
674
+ Scenario.replace(RequestDetails, "audience", "engineering managers", {
675
+ quote: "Actually, engineering managers",
662
676
  }),
663
677
  )
664
678
  ```
@@ -703,10 +717,10 @@ const InjectionPolicy = defineModelGuard({
703
717
  PolicyService.checkCall({ messages, call }),
704
718
  })
705
719
 
706
- const Matching = Stage.tools({
707
- name: "matching",
708
- instructions: ["Route the completed brief to one search."],
709
- tools: [SearchAgencies],
720
+ const Lookup = Stage.tools({
721
+ name: "lookup",
722
+ instructions: ["Route the completed request to one resource lookup."],
723
+ tools: [FindResources],
710
724
  guards: [InjectionPolicy],
711
725
  })
712
726
  ```
@@ -3,7 +3,7 @@ import { StructuredChatModel, type ChatModelUnavailableReasonSchema, type Struct
3
3
  import type { ModelToolDefinition } from "../core/tool.js";
4
4
  import { type JsonValue } from "../core/json-value.js";
5
5
  /** Bounded timeout for one provider tool-call request. */
6
- export declare const StructuredChatRequestTimeoutSchema: Schema.filter<Schema.filter<typeof Schema.Number>>;
6
+ export declare const StructuredChatRequestTimeoutSchema: Schema.Number;
7
7
  interface OpenAICompatibleMessage {
8
8
  readonly role: "system" | "user";
9
9
  readonly content: string;
@@ -21,10 +21,10 @@ type OpenAICompatibleInputValue = JsonValue | ReadonlyArray<OpenAICompatibleMess
21
21
  interface OpenAICompatibleInput {
22
22
  readonly [key: string]: OpenAICompatibleInputValue;
23
23
  }
24
- declare const OpenAICompatibleToolArgumentsSchema: Schema.Literal<["guided", "strict"]>;
24
+ declare const OpenAICompatibleToolArgumentsSchema: Schema.Literals<readonly ["guided", "strict"]>;
25
25
  type OpenAICompatibleToolArguments = Schema.Schema.Type<typeof OpenAICompatibleToolArgumentsSchema>;
26
26
  /** Bounded provider model identifier used for routing and diagnostics. */
27
- export declare const StructuredChatModelIdSchema: Schema.filter<typeof Schema.NonEmptyTrimmedString>;
27
+ export declare const StructuredChatModelIdSchema: Schema.Trimmed;
28
28
  /** Bounded provider model identifier used for routing and diagnostics. */
29
29
  export type StructuredChatModelId = Schema.Schema.Type<typeof StructuredChatModelIdSchema>;
30
30
  /** One provider request with its parsed model identifier. */
@@ -44,7 +44,7 @@ export interface CloudflareWorkersAIProviderConfig extends OpenAICompatibleProvi
44
44
  export interface OpenAIProviderConfig extends OpenAICompatibleProviderConfig {
45
45
  }
46
46
  /** Stable identifier for a built-in model provider. */
47
- export declare const StructuredChatProviderIdSchema: Schema.Literal<["cloudflare-workers-ai", "openai"]>;
47
+ export declare const StructuredChatProviderIdSchema: Schema.Literals<readonly ["cloudflare-workers-ai", "openai"]>;
48
48
  /** Stable identifier for a built-in model provider. */
49
49
  export type StructuredChatProviderId = Schema.Schema.Type<typeof StructuredChatProviderIdSchema>;
50
50
  interface StructuredChatProviderRuntime {
@@ -1,13 +1,19 @@
1
- import { Effect, Either, Layer, Schema } from "effect";
1
+ import { Effect, Exit, Layer, Schema } from "effect";
2
2
  import { ChatModelUnavailable, StructuredChatModel, UnsupportedModelToolSchema, } from "../core/model.js";
3
3
  import { JsonValueSchema, } from "../core/json-value.js";
4
4
  /** Bounded timeout for one provider tool-call request. */
5
- export const StructuredChatRequestTimeoutSchema = Schema.Number.pipe(Schema.int(), Schema.between(1, 60_000));
6
- const OpenAICompatibleToolArgumentsSchema = Schema.Literal("guided", "strict");
5
+ export const StructuredChatRequestTimeoutSchema = Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 60_000 }));
6
+ const OpenAICompatibleToolArgumentsSchema = Schema.Literals([
7
+ "guided",
8
+ "strict",
9
+ ]);
7
10
  /** Bounded provider model identifier used for routing and diagnostics. */
8
- export const StructuredChatModelIdSchema = Schema.NonEmptyTrimmedString.pipe(Schema.maxLength(200));
11
+ export const StructuredChatModelIdSchema = Schema.Trimmed.check(Schema.isNonEmpty(), Schema.isMaxLength(200));
9
12
  /** Stable identifier for a built-in model provider. */
10
- export const StructuredChatProviderIdSchema = Schema.Literal("cloudflare-workers-ai", "openai");
13
+ export const StructuredChatProviderIdSchema = Schema.Literals([
14
+ "cloudflare-workers-ai",
15
+ "openai",
16
+ ]);
11
17
  const StructuredChatProviderRuntime = Symbol("@popcomputer/structured-chat/StructuredChatProviderRuntime");
12
18
  const openAIModelSupportsStrictToolArguments = (model) => /^(?:chat-latest$|gpt-4o(?:-|$)|gpt-4\.1(?:-|$)|gpt-5(?:[.-]|$)|o3(?:-|$)|o4(?:-|$))/u.test(model);
13
19
  const makeProvider = (id, config, toolArguments) => {
@@ -45,28 +51,24 @@ export const ModelProvider = {
45
51
  },
46
52
  };
47
53
  const ToolCallResponseSchema = Schema.Struct({
48
- choices: Schema.Tuple(Schema.Struct({
49
- message: Schema.Struct({
50
- tool_calls: Schema.Tuple(Schema.Struct({
51
- function: Schema.Struct({
52
- name: Schema.NonEmptyTrimmedString.pipe(Schema.maxLength(100)),
53
- arguments: Schema.String.pipe(Schema.maxLength(20_000)),
54
- }),
55
- })),
54
+ choices: Schema.Tuple([
55
+ Schema.Struct({
56
+ message: Schema.Struct({
57
+ tool_calls: Schema.Tuple([
58
+ Schema.Struct({
59
+ function: Schema.Struct({
60
+ name: Schema.Trimmed.check(Schema.isNonEmpty(), Schema.isMaxLength(100)),
61
+ arguments: Schema.String.check(Schema.isMaxLength(20_000)),
62
+ }),
63
+ }),
64
+ ]),
65
+ }),
56
66
  }),
57
- })),
67
+ ]),
58
68
  });
59
69
  const unavailable = (reason) => new ChatModelUnavailable({ reason });
60
- const parseJson = (input) => Effect.try({
61
- // SAFETY: JSON.parse without a reviver can only return a JSON value when
62
- // parsing succeeds; failures are mapped to the typed unavailable reason.
63
- try: () => JSON.parse(input),
64
- catch: () => unavailable("invalid_response"),
65
- });
66
- const JsonSchemaObjectSchema = Schema.Record({
67
- key: Schema.String,
68
- value: JsonValueSchema,
69
- });
70
+ const parseJson = (input) => Schema.decodeEffect(Schema.fromJsonString(JsonValueSchema))(input).pipe(Effect.mapError(() => unavailable("invalid_response")));
71
+ const JsonSchemaObjectSchema = Schema.Record(Schema.String, JsonValueSchema);
70
72
  const isJsonSchemaObject = (value) => Schema.is(JsonSchemaObjectSchema)(value);
71
73
  const appendJsonPointer = (path, segment) => `${path}/${segment.replaceAll("~", "~0").replaceAll("/", "~1")}`;
72
74
  const findStrictObjectIssue = (schema, path) => {
@@ -145,15 +147,15 @@ const findStrictSchemaIssue = (schema) => {
145
147
  return findStrictObjectIssue(schema, "#");
146
148
  };
147
149
  const strictToolIssue = (tool) => {
148
- const parsedSchema = Schema.decodeUnknownEither(JsonSchemaObjectSchema)(tool.inputSchema, { onExcessProperty: "error" });
149
- if (Either.isLeft(parsedSchema)) {
150
+ const parsedSchema = Schema.decodeUnknownExit(JsonSchemaObjectSchema)(tool.inputSchema, { onExcessProperty: "error" });
151
+ if (Exit.isFailure(parsedSchema)) {
150
152
  return new UnsupportedModelToolSchema({
151
153
  tool: tool.name,
152
154
  path: "#",
153
155
  reason: "root_not_object",
154
156
  });
155
157
  }
156
- const issue = findStrictSchemaIssue(parsedSchema.right);
158
+ const issue = findStrictSchemaIssue(parsedSchema.value);
157
159
  return issue === undefined
158
160
  ? undefined
159
161
  : new UnsupportedModelToolSchema({
@@ -215,10 +217,10 @@ export const makeStructuredChatModel = (config) => {
215
217
  requestTool: (request) => toProviderInput(request, runtime.requestOptions, runtime.toolArguments).pipe(Effect.flatMap((input) => Effect.tryPromise({
216
218
  try: (signal) => runtime.complete(input, signal),
217
219
  catch: (cause) => unavailable(classifyError(cause)),
218
- })), Effect.timeoutFail({
220
+ })), Effect.timeoutOrElse({
219
221
  duration: timeoutMilliseconds,
220
- onTimeout: () => unavailable("timed_out"),
221
- }), Effect.flatMap((response) => Schema.decodeUnknown(ToolCallResponseSchema)(response).pipe(Effect.mapError(() => unavailable("invalid_response")))), Effect.flatMap((response) => {
222
+ orElse: () => Effect.fail(unavailable("timed_out")),
223
+ }), Effect.flatMap((response) => Schema.decodeUnknownEffect(ToolCallResponseSchema)(response).pipe(Effect.mapError(() => unavailable("invalid_response")))), Effect.flatMap((response) => {
222
224
  const tool = response.choices[0].message.tool_calls[0].function;
223
225
  return parseJson(tool.arguments).pipe(Effect.map((arguments_) => ({
224
226
  name: tool.name,
@@ -228,4 +230,4 @@ export const makeStructuredChatModel = (config) => {
228
230
  };
229
231
  };
230
232
  /** Build an Effect layer for one provider-backed structured chat model. */
231
- export const structuredChatModelLayer = (config) => Layer.succeed(StructuredChatModel, makeStructuredChatModel(config));
233
+ export const structuredChatModelLayer = (config) => Layer.succeed(StructuredChatModel, StructuredChatModel.of(makeStructuredChatModel(config)));
@@ -1,14 +1,14 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import type { ChoiceQuestion, FixedQuestion, QuestionDefinition, QuestionDefinitionContract } from "./question.js";
3
3
  /** How strongly a collect-stage answer must be grounded in user messages. */
4
- export declare const AnswerModeSchema: Schema.Literal<["semantic", "explicit", "confirmed"]>;
4
+ export declare const AnswerModeSchema: Schema.Literals<readonly ["semantic", "explicit", "confirmed"]>;
5
5
  /** How strongly a collect-stage answer must be grounded in user messages. */
6
6
  export type AnswerMode = Schema.Schema.Type<typeof AnswerModeSchema>;
7
7
  /** Minimum runtime shape retained for every collect-stage answer. */
8
8
  export interface AnswerDefinitionContract {
9
9
  readonly _tag: "AnswerDefinition";
10
10
  readonly mode: AnswerMode;
11
- readonly schema: Schema.Schema.AnyNoContext;
11
+ readonly schema: Schema.ConstraintCodec<unknown, unknown>;
12
12
  readonly description: string;
13
13
  readonly question: QuestionDefinitionContract;
14
14
  readonly validate?: (value: never) => Effect.Effect<void, unknown, unknown>;
@@ -20,16 +20,16 @@ export interface AnswerDefinitionContract {
20
20
  };
21
21
  }
22
22
  /** One typed fact required by a collect stage. */
23
- export interface AnswerDefinition<Mode extends AnswerMode, ValueSchema extends Schema.Schema.AnyNoContext, Error = never, Requirements = never> extends AnswerDefinitionContract {
23
+ export interface AnswerDefinition<Mode extends AnswerMode, ValueSchema extends Schema.ConstraintCodec<unknown, unknown>, Error = never, Requirements = never> extends AnswerDefinitionContract {
24
24
  readonly mode: Mode;
25
25
  readonly schema: ValueSchema;
26
- readonly question: QuestionDefinition<Schema.Schema.Type<ValueSchema>>;
27
- readonly validate?: (value: Schema.Schema.Type<ValueSchema>) => Effect.Effect<void, Error, Requirements>;
26
+ readonly question: QuestionDefinition<ValueSchema["Type"]>;
27
+ readonly validate?: (value: ValueSchema["Type"]) => Effect.Effect<void, Error, Requirements>;
28
28
  readonly reject?: {
29
- readonly ask: FixedQuestion | ChoiceQuestion<Schema.Schema.Type<ValueSchema>>;
29
+ readonly ask: FixedQuestion | ChoiceQuestion<ValueSchema["Type"]>;
30
30
  };
31
31
  readonly escape?: {
32
- readonly value: Schema.Schema.Type<ValueSchema>;
32
+ readonly value: ValueSchema["Type"];
33
33
  };
34
34
  }
35
35
  interface DefineAnswerBase<Value> {
@@ -58,12 +58,12 @@ export interface DefineValidatedAnswerInput<Value, Error, Requirements> extends
58
58
  }
59
59
  /** Configuration shared by all answer grounding modes. */
60
60
  export type DefineAnswerInput<Value, Error = never, Requirements = never> = DefineUnvalidatedAnswerInput<Value> | DefineValidatedAnswerInput<Value, Error, Requirements>;
61
- declare function semantic<ValueSchema extends Schema.Schema.AnyNoContext>(schema: ValueSchema, input: DefineUnvalidatedAnswerInput<Schema.Schema.Type<ValueSchema>>): AnswerDefinition<"semantic", ValueSchema, never, never>;
62
- declare function semantic<ValueSchema extends Schema.Schema.AnyNoContext, Error, Requirements>(schema: ValueSchema, input: DefineValidatedAnswerInput<Schema.Schema.Type<ValueSchema>, Error, Requirements>): AnswerDefinition<"semantic", ValueSchema, Error, Requirements>;
63
- declare function explicit<ValueSchema extends Schema.Schema.AnyNoContext>(schema: ValueSchema, input: DefineUnvalidatedAnswerInput<Schema.Schema.Type<ValueSchema>>): AnswerDefinition<"explicit", ValueSchema, never, never>;
64
- declare function explicit<ValueSchema extends Schema.Schema.AnyNoContext, Error, Requirements>(schema: ValueSchema, input: DefineValidatedAnswerInput<Schema.Schema.Type<ValueSchema>, Error, Requirements>): AnswerDefinition<"explicit", ValueSchema, Error, Requirements>;
65
- declare function confirmed<ValueSchema extends Schema.Schema.AnyNoContext>(schema: ValueSchema, input: DefineUnvalidatedAnswerInput<Schema.Schema.Type<ValueSchema>>): AnswerDefinition<"confirmed", ValueSchema, never, never>;
66
- declare function confirmed<ValueSchema extends Schema.Schema.AnyNoContext, Error, Requirements>(schema: ValueSchema, input: DefineValidatedAnswerInput<Schema.Schema.Type<ValueSchema>, Error, Requirements>): AnswerDefinition<"confirmed", ValueSchema, Error, Requirements>;
61
+ declare function semantic<ValueSchema extends Schema.ConstraintCodec<unknown, unknown>>(schema: ValueSchema, input: DefineUnvalidatedAnswerInput<ValueSchema["Type"]>): AnswerDefinition<"semantic", ValueSchema, never, never>;
62
+ declare function semantic<ValueSchema extends Schema.ConstraintCodec<unknown, unknown>, Error, Requirements>(schema: ValueSchema, input: DefineValidatedAnswerInput<ValueSchema["Type"], Error, Requirements>): AnswerDefinition<"semantic", ValueSchema, Error, Requirements>;
63
+ declare function explicit<ValueSchema extends Schema.ConstraintCodec<unknown, unknown>>(schema: ValueSchema, input: DefineUnvalidatedAnswerInput<ValueSchema["Type"]>): AnswerDefinition<"explicit", ValueSchema, never, never>;
64
+ declare function explicit<ValueSchema extends Schema.ConstraintCodec<unknown, unknown>, Error, Requirements>(schema: ValueSchema, input: DefineValidatedAnswerInput<ValueSchema["Type"], Error, Requirements>): AnswerDefinition<"explicit", ValueSchema, Error, Requirements>;
65
+ declare function confirmed<ValueSchema extends Schema.ConstraintCodec<unknown, unknown>>(schema: ValueSchema, input: DefineUnvalidatedAnswerInput<ValueSchema["Type"]>): AnswerDefinition<"confirmed", ValueSchema, never, never>;
66
+ declare function confirmed<ValueSchema extends Schema.ConstraintCodec<unknown, unknown>, Error, Requirements>(schema: ValueSchema, input: DefineValidatedAnswerInput<ValueSchema["Type"], Error, Requirements>): AnswerDefinition<"confirmed", ValueSchema, Error, Requirements>;
67
67
  /** Constructors for semantic, explicit, and explicitly confirmed facts. */
68
68
  export declare const Answer: {
69
69
  readonly semantic: typeof semantic;