@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.
- package/LICENSE +0 -1
- package/README.md +175 -108
- package/dist/adapters/openai-compatible-model.d.ts +4 -4
- package/dist/adapters/openai-compatible-model.js +33 -31
- package/dist/core/answer.d.ts +13 -13
- package/dist/core/answer.js +21 -12
- package/dist/core/chat.d.ts +12 -15
- package/dist/core/chat.js +36 -27
- package/dist/core/collect-stage.d.ts +28 -15
- package/dist/core/collect-stage.js +58 -37
- package/dist/core/command.d.ts +1 -1
- package/dist/core/command.js +1 -1
- package/dist/core/debug-protocol.d.ts +126 -0
- package/dist/core/debug-protocol.js +19 -0
- package/dist/core/debug.d.ts +103 -0
- package/dist/core/debug.js +276 -0
- package/dist/core/json-value.d.ts +1 -1
- package/dist/core/json-value.js +8 -1
- package/dist/core/model-guard.d.ts +2 -3
- package/dist/core/model-guard.js +4 -4
- package/dist/core/model.d.ts +15 -19
- package/dist/core/model.js +22 -10
- package/dist/core/protocol.d.ts +84 -79
- package/dist/core/protocol.js +18 -12
- package/dist/core/question.js +17 -15
- package/dist/core/repair.js +1 -1
- package/dist/core/session.d.ts +22 -28
- package/dist/core/session.js +16 -7
- package/dist/core/stage-name.d.ts +1 -1
- package/dist/core/stage-name.js +1 -1
- package/dist/core/stage.d.ts +4 -4
- package/dist/core/stage.js +11 -8
- package/dist/core/tool-set.js +6 -6
- package/dist/core/tool.d.ts +36 -39
- package/dist/core/tool.js +58 -31
- package/dist/core/view.d.ts +64 -39
- package/dist/core/view.js +12 -15
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integrations/assistant-ui-debug.d.ts +25 -0
- package/dist/integrations/assistant-ui-debug.js +1162 -0
- package/dist/integrations/assistant-ui.d.ts +10 -3
- package/dist/integrations/assistant-ui.js +48 -18
- package/dist/testing/in-memory-session-store.js +1 -1
- package/dist/testing/scenario.js +6 -6
- package/examples/answer-modes.ts +60 -49
- package/examples/prompt-injection-policy.ts +20 -20
- package/examples/resource-search.ts +104 -0
- package/package.json +15 -3
- package/examples/agency-search.ts +0 -101
package/LICENSE
CHANGED
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 {
|
|
31
|
+
import { Schema } from "effect"
|
|
32
32
|
|
|
33
|
-
const
|
|
34
|
-
name: "
|
|
33
|
+
const ResultCards = defineView({
|
|
34
|
+
name: "result_cards",
|
|
35
35
|
version: 1,
|
|
36
36
|
schema: Schema.Struct({
|
|
37
|
-
|
|
37
|
+
results: Schema.Array(
|
|
38
38
|
Schema.Struct({
|
|
39
39
|
id: Schema.String,
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
title: Schema.String,
|
|
41
|
+
summary: Schema.String,
|
|
42
42
|
}),
|
|
43
43
|
),
|
|
44
44
|
}),
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
const
|
|
48
|
-
name: "
|
|
49
|
-
description: "Find
|
|
50
|
-
input: Schema.Struct({
|
|
51
|
-
|
|
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(
|
|
54
|
-
Tool.present(
|
|
55
|
+
Tool.modelResult(ResourceEvidenceSchema, ({ evidence }) => evidence),
|
|
56
|
+
Tool.present(ResultCards, ({ results }) => ({ results })),
|
|
55
57
|
)
|
|
56
58
|
|
|
57
|
-
const
|
|
58
|
-
name: "
|
|
59
|
-
instructions: ["Route the completed
|
|
60
|
-
tools: [
|
|
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
|
-
`
|
|
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 {
|
|
92
|
+
import { SearchKnowledgeBase } from "./knowledge-graph.js"
|
|
87
93
|
|
|
88
|
-
const
|
|
89
|
-
name: "
|
|
90
|
-
description: "Find
|
|
91
|
-
input: Schema.Struct({
|
|
92
|
-
|
|
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(
|
|
95
|
-
Tool.present(
|
|
102
|
+
Tool.modelResult(ResourceEvidenceSchema, toModelEvidence),
|
|
103
|
+
Tool.present(ResultCards, toResultCards),
|
|
96
104
|
)
|
|
97
105
|
```
|
|
98
106
|
|
|
99
|
-
`
|
|
100
|
-
combine direct
|
|
101
|
-
typed
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
119
|
-
name: "
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
137
|
+
"What would you like help accomplishing?",
|
|
131
138
|
{
|
|
132
139
|
minimumOptions: 3,
|
|
133
140
|
maximumOptions: 5,
|
|
134
141
|
fallbackOptions: [
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
142
|
+
"Understand a topic",
|
|
143
|
+
"Compare available options",
|
|
144
|
+
"Plan the next steps",
|
|
138
145
|
],
|
|
139
146
|
},
|
|
140
147
|
),
|
|
141
148
|
}),
|
|
142
|
-
|
|
143
|
-
description: "
|
|
144
|
-
ask: Question.fixed("
|
|
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
|
|
150
|
-
name: "
|
|
156
|
+
const ResourceFinder = defineChat({
|
|
157
|
+
name: "resource_finder",
|
|
151
158
|
version: 1,
|
|
152
|
-
stages: [
|
|
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
|
|
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
|
|
224
|
+
const goal = ResourceFinder.getAcceptedAnswer(
|
|
218
225
|
reply.turn.state,
|
|
219
|
-
|
|
220
|
-
"
|
|
226
|
+
RequestDetails,
|
|
227
|
+
"goal",
|
|
221
228
|
)
|
|
222
229
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
238
|
-
"
|
|
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
|
|
249
|
+
const ResultLimit = Answer.explicit(Schema.Number, {
|
|
243
250
|
// The description remains model-visible, so repeat acceptance constraints.
|
|
244
|
-
description: "
|
|
245
|
-
ask: Question.fixed("
|
|
246
|
-
validate: (
|
|
247
|
-
|
|
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(
|
|
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*
|
|
317
|
-
namespace:
|
|
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("
|
|
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
|
-
|
|
367
|
+
lookup without restarting the collection stage:
|
|
361
368
|
|
|
362
369
|
```txt
|
|
363
|
-
User: We need a
|
|
364
|
-
Assistant: [
|
|
365
|
-
User:
|
|
366
|
-
Assistant: [refined
|
|
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
|
|
393
|
-
name: "
|
|
394
|
-
description: "
|
|
395
|
-
input:
|
|
399
|
+
const CreateRequest = defineCommand({
|
|
400
|
+
name: "create_request",
|
|
401
|
+
description: "Create the confirmed request once.",
|
|
402
|
+
input: CreateRequestInput,
|
|
396
403
|
execute: (input, { commandId }) =>
|
|
397
|
-
|
|
404
|
+
Requests.create(input, { idempotencyKey: commandId }),
|
|
398
405
|
}).pipe(
|
|
399
|
-
Tool.present(
|
|
406
|
+
Tool.present(RequestReceipt, toRequestReceipt),
|
|
400
407
|
)
|
|
401
408
|
|
|
402
|
-
const
|
|
403
|
-
name: "
|
|
404
|
-
instructions: ["
|
|
405
|
-
command:
|
|
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
|
|
435
|
-
name: "
|
|
441
|
+
const RepairableResourceFinder = defineChat({
|
|
442
|
+
name: "resource_finder",
|
|
436
443
|
version: 2,
|
|
437
|
-
stages: [
|
|
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
|
|
554
|
-
render:
|
|
560
|
+
const ResultCardsUI = makeAssistantView(ResultCards, {
|
|
561
|
+
render: ResultCardsComponent,
|
|
555
562
|
fallback: InvalidCardFallback,
|
|
556
563
|
})
|
|
557
564
|
|
|
558
565
|
const QuestionUI = makeAssistantView(CollectQuestionView, {
|
|
559
|
-
render:
|
|
566
|
+
render: RequestQuestion,
|
|
560
567
|
fallback: InvalidQuestionFallback,
|
|
561
568
|
})
|
|
562
569
|
|
|
563
570
|
const model = makeAssistantChatModelAdapter({
|
|
564
|
-
endpoint: "/api/
|
|
571
|
+
endpoint: "/api/resource-finder/turn",
|
|
565
572
|
})
|
|
566
573
|
|
|
567
|
-
export function
|
|
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
|
-
<
|
|
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*
|
|
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*
|
|
667
|
+
const call = yield* Lookup.plan(messages)
|
|
608
668
|
|
|
609
669
|
// Application-owned review or approval.
|
|
610
|
-
const result = yield*
|
|
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(
|
|
631
|
-
|
|
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*
|
|
703
|
+
const reply = yield* ResourceFinder.reply({
|
|
637
704
|
sessionId: "scenario-1",
|
|
638
|
-
message: "
|
|
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(
|
|
661
|
-
quote: "Actually,
|
|
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
|
|
707
|
-
name: "
|
|
708
|
-
instructions: ["Route the completed
|
|
709
|
-
tools: [
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
6
|
-
const OpenAICompatibleToolArgumentsSchema = Schema.
|
|
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.
|
|
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.
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
61
|
-
|
|
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.
|
|
149
|
-
if (
|
|
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.
|
|
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.
|
|
220
|
+
})), Effect.timeoutOrElse({
|
|
219
221
|
duration: timeoutMilliseconds,
|
|
220
|
-
|
|
221
|
-
}), Effect.flatMap((response) => Schema.
|
|
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)));
|