@popcomputer/structured-chat 0.1.0 → 0.2.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 (50) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +175 -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 +28 -15
  10. package/dist/core/collect-stage.js +58 -37
  11. package/dist/core/command.d.ts +1 -1
  12. package/dist/core/command.js +1 -1
  13. package/dist/core/debug-protocol.d.ts +126 -0
  14. package/dist/core/debug-protocol.js +19 -0
  15. package/dist/core/debug.d.ts +103 -0
  16. package/dist/core/debug.js +276 -0
  17. package/dist/core/json-value.d.ts +1 -1
  18. package/dist/core/json-value.js +8 -1
  19. package/dist/core/model-guard.d.ts +2 -3
  20. package/dist/core/model-guard.js +4 -4
  21. package/dist/core/model.d.ts +15 -19
  22. package/dist/core/model.js +22 -10
  23. package/dist/core/protocol.d.ts +84 -79
  24. package/dist/core/protocol.js +18 -12
  25. package/dist/core/question.js +17 -15
  26. package/dist/core/repair.js +1 -1
  27. package/dist/core/session.d.ts +22 -28
  28. package/dist/core/session.js +16 -7
  29. package/dist/core/stage-name.d.ts +1 -1
  30. package/dist/core/stage-name.js +1 -1
  31. package/dist/core/stage.d.ts +4 -4
  32. package/dist/core/stage.js +11 -8
  33. package/dist/core/tool-set.js +6 -6
  34. package/dist/core/tool.d.ts +36 -39
  35. package/dist/core/tool.js +58 -31
  36. package/dist/core/view.d.ts +64 -39
  37. package/dist/core/view.js +12 -15
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +2 -0
  40. package/dist/integrations/assistant-ui-debug.d.ts +25 -0
  41. package/dist/integrations/assistant-ui-debug.js +1162 -0
  42. package/dist/integrations/assistant-ui.d.ts +10 -3
  43. package/dist/integrations/assistant-ui.js +48 -18
  44. package/dist/testing/in-memory-session-store.js +1 -1
  45. package/dist/testing/scenario.js +6 -6
  46. package/examples/answer-modes.ts +60 -49
  47. package/examples/prompt-injection-policy.ts +20 -20
  48. package/examples/resource-search.ts +104 -0
  49. package/package.json +15 -3
  50. 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
  )
@@ -593,21 +600,74 @@ that expose message editing, regeneration, or persistent branching should pair
593
600
  it with an application-owned branch/session policy rather than treating browser
594
601
  history as authoritative.
595
602
 
603
+ ## Inspect development state
604
+
605
+ The optional debug inspector shows the current stage, stage progress, required
606
+ fields, accepted values, issued questions, and supporting evidence. It uses a
607
+ small package-owned panel inspired by DialKit, without adding DialKit or another
608
+ runtime dependency.
609
+
610
+ Debug data uses a separate, explicit response contract. Select it on an
611
+ authenticated development endpoint:
612
+
613
+ ```ts
614
+ import {
615
+ presentChatDebugReply,
616
+ presentChatReply,
617
+ } from "@popcomputer/structured-chat"
618
+
619
+ const response = debugAccessGranted
620
+ ? yield* presentChatDebugReply(
621
+ ResourceFinder,
622
+ { ...reply, sessionId: publicSessionId },
623
+ { inspection: { evidence: "include" } },
624
+ )
625
+ : yield* presentChatReply({ ...reply, sessionId: publicSessionId })
626
+ ```
627
+
628
+ Connect that endpoint to the inspector store:
629
+
630
+ ```tsx
631
+ import { makeAssistantChatModelAdapter } from "@popcomputer/structured-chat/assistant-ui"
632
+ import {
633
+ createStructuredChatDebugStore,
634
+ StructuredChatDebugPanel,
635
+ } from "@popcomputer/structured-chat/assistant-ui/debug"
636
+
637
+ const debugStore = createStructuredChatDebugStore()
638
+ const model = makeAssistantChatModelAdapter({
639
+ endpoint: "/api/resource-finder/debug/turn",
640
+ onDebugSnapshot: debugStore.receive,
641
+ })
642
+
643
+ export function ResourceFinderDebugPanel() {
644
+ return <StructuredChatDebugPanel store={debugStore} />
645
+ }
646
+ ```
647
+
648
+ Without `onDebugSnapshot`, the normal adapter continues to reject a response
649
+ containing debug data. Hiding or unmounting the panel is not an authorization
650
+ boundary: the server must decide whether to emit the debug response. Use
651
+ `evidence: "omit"` when transcript quotes should not cross that boundary.
652
+ Create one store per chat runtime; it reflects the most recently completed
653
+ debug response. Observer failures are isolated and never change the outcome of
654
+ the persisted chat turn.
655
+
596
656
  ## Plan without executing
597
657
 
598
658
  The default remains one call:
599
659
 
600
660
  ```ts
601
- const result = yield* Matching.run(messages)
661
+ const result = yield* Lookup.run(messages)
602
662
  ```
603
663
 
604
664
  Approval flows, dry runs, and evals can stop after a strictly parsed proposal:
605
665
 
606
666
  ```ts
607
- const call = yield* Matching.plan(messages)
667
+ const call = yield* Lookup.plan(messages)
608
668
 
609
669
  // Application-owned review or approval.
610
- const result = yield* Matching.toolSet.execute(call)
670
+ const result = yield* Lookup.toolSet.execute(call)
611
671
  ```
612
672
 
613
673
  `plan` uses the same trusted instructions, closed tool set, guards, provider
@@ -627,15 +687,22 @@ import {
627
687
  } from "@popcomputer/structured-chat/testing"
628
688
 
629
689
  const ModelTest = Scenario.model(
630
- Scenario.answers(ProjectBrief, {
631
- location: Scenario.quoted("Leeds", { quote: "based in Leeds" }),
690
+ Scenario.answers(RequestDetails, {
691
+ goal: Scenario.quoted("prepare an onboarding plan", {
692
+ quote: "prepare an onboarding plan",
693
+ }),
694
+ audience: Scenario.quoted("new team members", {
695
+ quote: "new team members",
696
+ }),
697
+ }),
698
+ Scenario.call(FindResources, {
699
+ query: "onboarding plan for new team members",
632
700
  }),
633
- Scenario.call(SearchAgencies, { query: "Leeds public services" }),
634
701
  )
635
702
 
636
- const reply = yield* Matchmaker.reply({
703
+ const reply = yield* ResourceFinder.reply({
637
704
  sessionId: "scenario-1",
638
- message: "We are based in Leeds.",
705
+ message: "Help me prepare an onboarding plan for new team members.",
639
706
  }).pipe(
640
707
  Effect.provide(Layer.merge(ModelTest, inMemoryChatSessionStore)),
641
708
  )
@@ -657,8 +724,8 @@ rules:
657
724
 
658
725
  ```ts
659
726
  Scenario.repairs(
660
- Scenario.replace(ProjectBrief, "location", "Manchester", {
661
- quote: "Actually, Manchester",
727
+ Scenario.replace(RequestDetails, "audience", "engineering managers", {
728
+ quote: "Actually, engineering managers",
662
729
  }),
663
730
  )
664
731
  ```
@@ -703,10 +770,10 @@ const InjectionPolicy = defineModelGuard({
703
770
  PolicyService.checkCall({ messages, call }),
704
771
  })
705
772
 
706
- const Matching = Stage.tools({
707
- name: "matching",
708
- instructions: ["Route the completed brief to one search."],
709
- tools: [SearchAgencies],
773
+ const Lookup = Stage.tools({
774
+ name: "lookup",
775
+ instructions: ["Route the completed request to one resource lookup."],
776
+ tools: [FindResources],
710
777
  guards: [InjectionPolicy],
711
778
  })
712
779
  ```
@@ -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)));