@popcomputer/structured-chat 0.1.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 +22 -0
- package/README.md +780 -0
- package/dist/adapters/openai-compatible-model.d.ts +89 -0
- package/dist/adapters/openai-compatible-model.js +231 -0
- package/dist/core/answer.d.ts +73 -0
- package/dist/core/answer.js +60 -0
- package/dist/core/chat.d.ts +124 -0
- package/dist/core/chat.js +490 -0
- package/dist/core/collect-stage.d.ts +203 -0
- package/dist/core/collect-stage.js +645 -0
- package/dist/core/command.d.ts +16 -0
- package/dist/core/command.js +17 -0
- package/dist/core/definition.d.ts +10 -0
- package/dist/core/definition.js +14 -0
- package/dist/core/json-value.d.ts +11 -0
- package/dist/core/json-value.js +3 -0
- package/dist/core/model-guard.d.ts +52 -0
- package/dist/core/model-guard.js +37 -0
- package/dist/core/model.d.ts +99 -0
- package/dist/core/model.js +109 -0
- package/dist/core/protocol.d.ts +257 -0
- package/dist/core/protocol.js +153 -0
- package/dist/core/question.d.ts +52 -0
- package/dist/core/question.js +62 -0
- package/dist/core/repair.d.ts +14 -0
- package/dist/core/repair.js +9 -0
- package/dist/core/session.d.ts +78 -0
- package/dist/core/session.js +34 -0
- package/dist/core/stage-name.d.ts +3 -0
- package/dist/core/stage-name.js +3 -0
- package/dist/core/stage.d.ts +88 -0
- package/dist/core/stage.js +104 -0
- package/dist/core/tool-set.d.ts +49 -0
- package/dist/core/tool-set.js +66 -0
- package/dist/core/tool.d.ts +149 -0
- package/dist/core/tool.js +215 -0
- package/dist/core/view.d.ts +95 -0
- package/dist/core/view.js +69 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/integrations/assistant-ui.d.ts +95 -0
- package/dist/integrations/assistant-ui.js +114 -0
- package/dist/testing/in-memory-session-store.d.ts +4 -0
- package/dist/testing/in-memory-session-store.js +38 -0
- package/dist/testing/scenario.d.ts +59 -0
- package/dist/testing/scenario.js +147 -0
- package/dist/testing.d.ts +4 -0
- package/dist/testing.js +2 -0
- package/examples/agency-search.ts +101 -0
- package/examples/answer-modes.ts +92 -0
- package/examples/prompt-injection-policy.ts +66 -0
- package/package.json +89 -0
package/README.md
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
# @popcomputer/structured-chat
|
|
2
|
+
|
|
3
|
+
Schema-defined, server-owned chat workflows for Effect applications.
|
|
4
|
+
|
|
5
|
+
`@popcomputer/structured-chat` lets an application describe:
|
|
6
|
+
|
|
7
|
+
- the facts a conversation must collect;
|
|
8
|
+
- the tools available at each stage;
|
|
9
|
+
- the data returned to the model;
|
|
10
|
+
- the data rendered by the browser; and
|
|
11
|
+
- the order in which those capabilities become available.
|
|
12
|
+
|
|
13
|
+
The framework derives the model tool schemas, runtime validation, workflow
|
|
14
|
+
state, browser protocol, and Effect requirements from those definitions.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
bun add @popcomputer/structured-chat effect
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The published entry points are ESM-only and support Node.js 22 or newer.
|
|
21
|
+
|
|
22
|
+
## Start with one tool
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import {
|
|
26
|
+
defineTool,
|
|
27
|
+
defineView,
|
|
28
|
+
Stage,
|
|
29
|
+
Tool,
|
|
30
|
+
} from "@popcomputer/structured-chat"
|
|
31
|
+
import { Effect, Schema } from "effect"
|
|
32
|
+
|
|
33
|
+
const AgencyCards = defineView({
|
|
34
|
+
name: "agency_cards",
|
|
35
|
+
version: 1,
|
|
36
|
+
schema: Schema.Struct({
|
|
37
|
+
agencies: Schema.Array(
|
|
38
|
+
Schema.Struct({
|
|
39
|
+
id: Schema.String,
|
|
40
|
+
name: Schema.String,
|
|
41
|
+
reason: Schema.String,
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
}),
|
|
45
|
+
})
|
|
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),
|
|
52
|
+
}).pipe(
|
|
53
|
+
Tool.modelResult(AgencyEvidenceSchema, ({ evidence }) => evidence),
|
|
54
|
+
Tool.present(AgencyCards, ({ agencies }) => ({ agencies })),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const Matching = Stage.tools({
|
|
58
|
+
name: "matching",
|
|
59
|
+
instructions: ["Route the completed brief to one agency search."],
|
|
60
|
+
tools: [SearchAgencies],
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`SearchAgencies` is the single source of truth for one capability. Its input
|
|
65
|
+
schema becomes both the model-facing JSON Schema and the authoritative runtime
|
|
66
|
+
parser. Its Effect error and service requirements remain typed.
|
|
67
|
+
|
|
68
|
+
The three result surfaces are deliberately separate:
|
|
69
|
+
|
|
70
|
+
| Surface | Purpose | Typical contents |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| Server result | Trusted application work | rows, graph nodes, internal scores |
|
|
73
|
+
| Model result | Bounded reasoning evidence | opaque refs, summaries, citations |
|
|
74
|
+
| Views | Browser display | cards, links, public images |
|
|
75
|
+
|
|
76
|
+
Internal data does not reach the model or browser merely because a tool loaded
|
|
77
|
+
it.
|
|
78
|
+
|
|
79
|
+
## Compose directly with document-graph
|
|
80
|
+
|
|
81
|
+
Structured chat does not wrap or replace retrieval. An application-defined
|
|
82
|
+
`@popcomputer/document-graph` handle is already an Effect and can be the tool
|
|
83
|
+
execution directly:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { FindAgencies } from "./agency-graph.js"
|
|
87
|
+
|
|
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 }),
|
|
93
|
+
}).pipe(
|
|
94
|
+
Tool.modelResult(AgencyEvidenceSchema, toModelEvidence),
|
|
95
|
+
Tool.present(AgencyCards, toAgencyCards),
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
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.
|
|
104
|
+
|
|
105
|
+
## Put a free-form conversation on rails
|
|
106
|
+
|
|
107
|
+
Collection stages describe meaning, not a fixed form wizard:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import {
|
|
111
|
+
Answer,
|
|
112
|
+
defineChat,
|
|
113
|
+
Question,
|
|
114
|
+
Stage,
|
|
115
|
+
} from "@popcomputer/structured-chat"
|
|
116
|
+
import { Schema } from "effect"
|
|
117
|
+
|
|
118
|
+
const ProjectBrief = Stage.collect({
|
|
119
|
+
name: "project_brief",
|
|
120
|
+
questions: {
|
|
121
|
+
guidance:
|
|
122
|
+
"Ask one conversational question at a time and briefly explain why the answer improves the match.",
|
|
123
|
+
escape: "Not sure yet",
|
|
124
|
+
},
|
|
125
|
+
fields: {
|
|
126
|
+
priority: Answer.semantic(Schema.NonEmptyTrimmedString, {
|
|
127
|
+
description:
|
|
128
|
+
"The unmet need the client most wants an agency to solve.",
|
|
129
|
+
ask: Question.adaptiveChoice(
|
|
130
|
+
"Where would outside agency expertise help most?",
|
|
131
|
+
{
|
|
132
|
+
minimumOptions: 3,
|
|
133
|
+
maximumOptions: 5,
|
|
134
|
+
fallbackOptions: [
|
|
135
|
+
"Launch or grow a product",
|
|
136
|
+
"Build the brand long term",
|
|
137
|
+
"Fix a performance problem",
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
),
|
|
141
|
+
}),
|
|
142
|
+
location: Answer.explicit(Schema.NonEmptyTrimmedString, {
|
|
143
|
+
description: "Where the client is based.",
|
|
144
|
+
ask: Question.fixed("Where are you based?"),
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const Matchmaker = defineChat({
|
|
150
|
+
name: "agency_matchmaker",
|
|
151
|
+
version: 1,
|
|
152
|
+
stages: [ProjectBrief, Matching],
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The model can understand an answer supplied naturally earlier in the
|
|
157
|
+
conversation, but the runtime decides whether each field is complete and which
|
|
158
|
+
stage is active.
|
|
159
|
+
|
|
160
|
+
`questions.guidance` is trusted application policy for model-authored wording.
|
|
161
|
+
`questions.escape` adds the same uncertainty option to every browser question.
|
|
162
|
+
When the user chooses it, the server keeps the current field unresolved even if
|
|
163
|
+
the model incorrectly proposes the label as an answer; an adaptive question can
|
|
164
|
+
then explore the same need from another angle.
|
|
165
|
+
|
|
166
|
+
A field can resolve the escape instead of looping. Declare
|
|
167
|
+
`escape: { value }` on the answer and the stage accepts that
|
|
168
|
+
application-authored value when the user chooses the uncertainty option
|
|
169
|
+
while the field is pending. The value is validated against the answer schema
|
|
170
|
+
at definition time and requires `questions.escape` to be configured; a
|
|
171
|
+
confirmed field still requires its question to have been issued first.
|
|
172
|
+
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
|
|
174
|
+
unless the application wants it to.
|
|
175
|
+
|
|
176
|
+
For an adaptive choice, valid contextual model suggestions take precedence.
|
|
177
|
+
`fallbackOptions` guarantees useful buttons when a model or provider omits them;
|
|
178
|
+
the runtime validates both sources against the same option bounds and never
|
|
179
|
+
extracts choices from conversational prose.
|
|
180
|
+
|
|
181
|
+
- `Answer.semantic` instructs the model that it may infer a typed fact from
|
|
182
|
+
quoted user evidence.
|
|
183
|
+
- `Answer.explicit` instructs the model to require a direct user statement.
|
|
184
|
+
- `Answer.confirmed` is ignored until the server has actually issued that
|
|
185
|
+
question and the user then confirms it.
|
|
186
|
+
|
|
187
|
+
Only `confirmed` carries a server-enforced ordering guarantee: acceptance
|
|
188
|
+
requires an issued assistant question grounded at its exact transcript location
|
|
189
|
+
plus evidence from a later user message. Loaded sessions that violate that
|
|
190
|
+
ordering are rejected, and repair requires reconfirmation. The
|
|
191
|
+
`semantic`/`explicit` distinction is model instruction only — the server
|
|
192
|
+
verifies both the same way, as one exact quote from any user message. Choose
|
|
193
|
+
`confirmed` when the ordering guarantee must hold against a misbehaving model,
|
|
194
|
+
not just a well-prompted one.
|
|
195
|
+
|
|
196
|
+
| Answer mode | Server enforcement | Model interpretation |
|
|
197
|
+
| --- | --- | --- |
|
|
198
|
+
| `Answer.confirmed` | The exact assistant question must exist before the supporting user message | Treat the later statement as an explicit answer |
|
|
199
|
+
| `Answer.explicit` | Requires an exact quote from a user message | Accept only a directly stated value |
|
|
200
|
+
| `Answer.semantic` | Requires an exact quote from a user message | May infer the typed value from that evidence |
|
|
201
|
+
| No collect stage | The final tool stage is active immediately | No preliminary fact extraction |
|
|
202
|
+
|
|
203
|
+
See the compile-checked
|
|
204
|
+
[`answer-modes.ts`](./examples/answer-modes.ts) example for required Q&A,
|
|
205
|
+
free-form understanding, and completely open tool chat definitions.
|
|
206
|
+
|
|
207
|
+
Every proposed answer requires an exact quote from a user message. The model
|
|
208
|
+
does not calculate transcript positions: the runtime resolves the most recent
|
|
209
|
+
eligible user message, then persists its index with the typed value and quote
|
|
210
|
+
as one unit. Loaded sessions are rejected if that quote no longer points to the
|
|
211
|
+
same user message. Assistant text cannot become evidence.
|
|
212
|
+
|
|
213
|
+
Applications can read the typed value and its provenance without reaching into
|
|
214
|
+
the persisted state shape:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const priority = Matchmaker.getAcceptedAnswer(
|
|
218
|
+
reply.turn.state,
|
|
219
|
+
ProjectBrief,
|
|
220
|
+
"priority",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
priority?.value
|
|
224
|
+
priority?.evidence.messageIndex
|
|
225
|
+
priority?.evidence.quote
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
For semantic answers, the quote supports the model's inference; it does not
|
|
229
|
+
need to equal the typed value. Treat the quote as untrusted user content.
|
|
230
|
+
|
|
231
|
+
### Validate domain acceptance conversationally
|
|
232
|
+
|
|
233
|
+
Keep parsing and business acceptance separate when a structurally valid value
|
|
234
|
+
may still be unsuitable for the workflow:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
class BudgetTooLow extends Schema.TaggedError<BudgetTooLow>()(
|
|
238
|
+
"BudgetTooLow",
|
|
239
|
+
{ minimum: Schema.Number },
|
|
240
|
+
) {}
|
|
241
|
+
|
|
242
|
+
const Budget = Answer.explicit(Schema.Number, {
|
|
243
|
+
// 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
|
|
248
|
+
? Effect.void
|
|
249
|
+
: Effect.fail(new BudgetTooLow({ minimum: 5_000 })),
|
|
250
|
+
reject: {
|
|
251
|
+
ask: Question.fixed(
|
|
252
|
+
"Our minimum engagement is £5,000. Could you revise the budget?",
|
|
253
|
+
),
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`schema` is the structural, model-visible contract. `validate` runs on its
|
|
259
|
+
decoded Type-side value and may use Effect services. Its exact errors and
|
|
260
|
+
requirements flow through the stage and chat types. If it fails, the package
|
|
261
|
+
returns `AnswerValidationRejected` with the original typed error and the
|
|
262
|
+
trusted retry prompt; no answer, message, revision, or partial field set is
|
|
263
|
+
persisted.
|
|
264
|
+
|
|
265
|
+
When one proposal contains several answers, validators run sequentially in
|
|
266
|
+
field-definition order and stop at the first rejection. A later validator is
|
|
267
|
+
not started after an earlier field fails. This ordering is deliberate because
|
|
268
|
+
validators may use Effect services; combine independent checks inside a single
|
|
269
|
+
field validator if the application wants its own concurrency and error policy.
|
|
270
|
+
|
|
271
|
+
Present that non-progressing retry with the browser's previous session
|
|
272
|
+
reference, if one exists:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
const response = yield* presentAnswerValidationRejection({
|
|
276
|
+
rejection,
|
|
277
|
+
session: request.session,
|
|
278
|
+
})
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Retry prompts are fixed or choice questions in v1. They never require another
|
|
282
|
+
model call, and choice values remain server-only.
|
|
283
|
+
|
|
284
|
+
For adaptive questions, the model proposes a field together with its wording
|
|
285
|
+
and options. The runtime accepts that presentation only when the field matches
|
|
286
|
+
the server-selected first missing field. The model can phrase a question; it
|
|
287
|
+
cannot choose which workflow requirement comes next. Missing, invalid, or
|
|
288
|
+
wrong-field adaptive presentation falls back to the application-authored
|
|
289
|
+
question without suggested choices, so optional model wording cannot make the
|
|
290
|
+
workflow unavailable.
|
|
291
|
+
|
|
292
|
+
`Question.adaptive(goal, { fallback })` keeps those two voices separate: the
|
|
293
|
+
goal is model-facing phrasing guidance and is never shown to the user, while
|
|
294
|
+
the fallback is the user-facing question presented whenever no valid model
|
|
295
|
+
wording is available. Adaptive-choice options — model-authored and
|
|
296
|
+
`fallbackOptions` alike — are validated against the answer schema, because a
|
|
297
|
+
selected label is later submitted as that answer's value.
|
|
298
|
+
|
|
299
|
+
## Run one server action
|
|
300
|
+
|
|
301
|
+
The browser submits only its latest text and, after the first turn, an opaque
|
|
302
|
+
session reference. State, history, tools, and answers stay on the server.
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
import {
|
|
306
|
+
presentChatReply,
|
|
307
|
+
StructuredChatTurnRequestSchema,
|
|
308
|
+
Text,
|
|
309
|
+
} from "@popcomputer/structured-chat"
|
|
310
|
+
import { Effect } from "effect"
|
|
311
|
+
|
|
312
|
+
const program = Effect.gen(function* () {
|
|
313
|
+
const request = yield* parseRequest(StructuredChatTurnRequestSchema)
|
|
314
|
+
const publicSessionId = request.session?.id ?? crypto.randomUUID()
|
|
315
|
+
|
|
316
|
+
const reply = yield* Matchmaker.reply({
|
|
317
|
+
namespace: authenticatedUser.id,
|
|
318
|
+
sessionId: publicSessionId,
|
|
319
|
+
expectedRevision: request.session?.revision,
|
|
320
|
+
message: request.message,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
return yield* presentChatReply(
|
|
324
|
+
{ ...reply, sessionId: publicSessionId },
|
|
325
|
+
{
|
|
326
|
+
result: ({ result }) => [
|
|
327
|
+
Text.make("These are the strongest evidence-backed matches."),
|
|
328
|
+
...result.views,
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
)
|
|
332
|
+
})
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`ChatSessionStore` is a two-operation adapter: load a complete snapshot, then
|
|
336
|
+
atomically replace it at an expected revision. A stale or concurrent turn
|
|
337
|
+
fails with `ChatSessionConflict`. The package includes an in-memory adapter at
|
|
338
|
+
`@popcomputer/structured-chat/testing`; production applications should provide
|
|
339
|
+
a durable database adapter.
|
|
340
|
+
|
|
341
|
+
The application should generate initial public session IDs on the server and
|
|
342
|
+
pass the authenticated actor as a separate `namespace`. The persistence
|
|
343
|
+
identity is the tuple `(namespace, sessionId, chat, version)`: components are
|
|
344
|
+
never concatenated. Different component pairs cannot collide at delimiter
|
|
345
|
+
boundaries, and two individually valid IDs cannot become invalid merely
|
|
346
|
+
because they were joined. Browser-held IDs are references, not authorization.
|
|
347
|
+
|
|
348
|
+
A session stores at most 200 conversation messages. Each reply reserves room
|
|
349
|
+
for the user message and the largest possible assistant result before calling
|
|
350
|
+
the model or an application tool. A session with 198 messages may advance; a
|
|
351
|
+
session with 199 or 200 messages fails with `InvalidChatSession` and reason
|
|
352
|
+
`history_limit` without performing model, tool, or persistence work. Start a
|
|
353
|
+
new session at that boundary. History summarisation and compaction remain an
|
|
354
|
+
explicit application policy rather than silently changing conversation
|
|
355
|
+
meaning.
|
|
356
|
+
|
|
357
|
+
## Continue naturally after the first tool result
|
|
358
|
+
|
|
359
|
+
`Stage.tools(...)` stays active by default. A user can refine the previous
|
|
360
|
+
search without restarting the collection stage:
|
|
361
|
+
|
|
362
|
+
```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]
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
When a tool defines `Tool.modelResult(...)`, each bounded result is retained in
|
|
370
|
+
server-owned history as untrusted assistant context. That lets the next model
|
|
371
|
+
step resolve references to earlier results while keeping server-only data and
|
|
372
|
+
browser views out of the prompt. Retrieved result text never becomes trusted
|
|
373
|
+
instructions.
|
|
374
|
+
|
|
375
|
+
For a deliberately one-shot read, opt into completion explicitly:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const FetchReportOnce = Stage.tools({
|
|
379
|
+
name: "fetch_report_once",
|
|
380
|
+
instructions: ["Fetch the completed report once."],
|
|
381
|
+
tools: [FetchReport],
|
|
382
|
+
afterExecution: "complete",
|
|
383
|
+
})
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Run writes as terminal commands
|
|
387
|
+
|
|
388
|
+
Declare side effects honestly. A command receives the package-derived stable
|
|
389
|
+
ID that its application endpoint must use as an idempotency key:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
const Submit = defineCommand({
|
|
393
|
+
name: "submit_application",
|
|
394
|
+
description: "Submit the confirmed application once.",
|
|
395
|
+
input: SubmitApplicationInput,
|
|
396
|
+
execute: (input, { commandId }) =>
|
|
397
|
+
Applications.submit(input, { idempotencyKey: commandId }),
|
|
398
|
+
}).pipe(
|
|
399
|
+
Tool.present(SubmissionReceipt, toSubmissionReceipt),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
const SubmitApplication = Stage.command({
|
|
403
|
+
name: "submit_application",
|
|
404
|
+
instructions: ["Submit the confirmed application."],
|
|
405
|
+
command: Submit,
|
|
406
|
+
})
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
`Stage.command` accepts exactly one command, must be the final chat stage, and
|
|
410
|
+
always completes the chat. Commands cannot be added to repeatable
|
|
411
|
+
`Stage.tools` sets. Persisted command chats execute through `Chat.reply`; the
|
|
412
|
+
unscoped `Chat.run` seam refuses to run them because it has no session/revision
|
|
413
|
+
identity.
|
|
414
|
+
|
|
415
|
+
The command ID is an opaque SHA-256 identity derived from `(namespace, chat,
|
|
416
|
+
version, sessionId, expectedRevision, commandName)`. A retry after a failed
|
|
417
|
+
session-store write therefore reaches the application with the same ID. The
|
|
418
|
+
application's durable idempotent endpoint must:
|
|
419
|
+
|
|
420
|
+
- return the original outcome when the same ID and input are retried;
|
|
421
|
+
- reject reuse of one ID with different input; and
|
|
422
|
+
- commit its idempotency record atomically with the side effect.
|
|
423
|
+
|
|
424
|
+
This v1 design preserves the session store's single optimistic replacement per
|
|
425
|
+
turn. It does not pretend that one chat-state write can journal a command before
|
|
426
|
+
and after execution.
|
|
427
|
+
|
|
428
|
+
## Opt into bounded conversation repair
|
|
429
|
+
|
|
430
|
+
Repair is disabled by default. Enable it only on chats whose final stage is a
|
|
431
|
+
repeatable query:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
const Matchmaker = defineChat({
|
|
435
|
+
name: "agency_matchmaker",
|
|
436
|
+
version: 2,
|
|
437
|
+
stages: [ProjectBrief, Matching],
|
|
438
|
+
repair: Repair.standard({ maximumCorrections: 5 }),
|
|
439
|
+
})
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
On a persisted follow-up, the model sees a closed choice between the stage's
|
|
443
|
+
queries and `apply_conversation_repairs`. An ordinary follow-up chooses a query
|
|
444
|
+
and still uses one model request. A correction uses at most one bounded second
|
|
445
|
+
request after the package applies the typed transition:
|
|
446
|
+
|
|
447
|
+
- semantic and explicit answers are replaced in place only with fresh evidence
|
|
448
|
+
from the current user message, then the query is rerun;
|
|
449
|
+
- confirmed answers are cleared together with their issuance cursor, the chat
|
|
450
|
+
rewinds to the earliest affected collect stage, and the question is reissued;
|
|
451
|
+
- multiple confirmed corrections are retained as an ordered persisted
|
|
452
|
+
reconfirmation queue; and
|
|
453
|
+
- field validators run again before a replacement is accepted.
|
|
454
|
+
|
|
455
|
+
Repair cannot be enabled for a terminal query or command chat. Commands are
|
|
456
|
+
never offered to repair planning and never rerun. Enabling repair adds repair
|
|
457
|
+
state to persisted sessions, so bump the chat version when adding it to an
|
|
458
|
+
existing definition.
|
|
459
|
+
|
|
460
|
+
## Connect a model provider
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
import {
|
|
464
|
+
ModelProvider,
|
|
465
|
+
structuredChatModelLayer,
|
|
466
|
+
} from "@popcomputer/structured-chat"
|
|
467
|
+
|
|
468
|
+
const ModelLive = structuredChatModelLayer({
|
|
469
|
+
provider: ModelProvider.cloudflareWorkersAI({
|
|
470
|
+
model: "@cf/google/gemma-4-26b-a4b-it",
|
|
471
|
+
complete: ({ model, input }, signal) =>
|
|
472
|
+
env.AI.run(model, { ...input }, { signal }),
|
|
473
|
+
requestOptions: {
|
|
474
|
+
temperature: 0,
|
|
475
|
+
max_completion_tokens: 256,
|
|
476
|
+
},
|
|
477
|
+
}),
|
|
478
|
+
timeoutMilliseconds: 15_000,
|
|
479
|
+
classifyError: (cause) =>
|
|
480
|
+
workersAIBlocked(cause)
|
|
481
|
+
? "response_blocked"
|
|
482
|
+
: "request_failed",
|
|
483
|
+
})
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The application chooses a provider and model; the package owns the provider's
|
|
487
|
+
tool-call dialect and strongest safe schema guarantee. There is no
|
|
488
|
+
`toolArguments: "guided" | "strict"` switch for application code to get wrong.
|
|
489
|
+
|
|
490
|
+
For OpenAI, only the provider definition changes. Recognised Structured Outputs
|
|
491
|
+
model families use constrained function arguments automatically; unknown or
|
|
492
|
+
older model identifiers conservatively use schema guidance:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
const ModelLive = structuredChatModelLayer({
|
|
496
|
+
provider: ModelProvider.openAI({
|
|
497
|
+
model: "gpt-5.6-luna",
|
|
498
|
+
complete: ({ model, input }, signal) =>
|
|
499
|
+
openAI.chat.completions.create(
|
|
500
|
+
{ ...input, model },
|
|
501
|
+
{ signal },
|
|
502
|
+
),
|
|
503
|
+
}),
|
|
504
|
+
timeoutMilliseconds: 15_000,
|
|
505
|
+
})
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
The adapter always requires exactly one tool call, disables parallel tool
|
|
509
|
+
calls and streaming, separates trusted instructions from untrusted
|
|
510
|
+
conversation text, parses the provider envelope and JSON arguments, then lets
|
|
511
|
+
the selected Effect Schema validate them. Provider options cannot override
|
|
512
|
+
those invariants.
|
|
513
|
+
|
|
514
|
+
Cloudflare Workers AI uses schemas as generation guidance because its API does
|
|
515
|
+
not guarantee schema-constrained output. Invalid output is rejected at the
|
|
516
|
+
Effect Schema boundary and may receive the chat's one bounded repair attempt.
|
|
517
|
+
Recognised OpenAI models add `strict: true`. Before contacting OpenAI, the
|
|
518
|
+
adapter rejects schemas whose root is not an object, whose objects allow
|
|
519
|
+
additional properties, or whose object properties are optional.
|
|
520
|
+
|
|
521
|
+
Model optional values explicitly with `Schema.NullOr(...)` so the field remains
|
|
522
|
+
required while its value may be absent. If a constrained provider receives an
|
|
523
|
+
incompatible tool, the request fails before transport with
|
|
524
|
+
`UnsupportedModelToolSchema`, including the safe tool name, schema path, and
|
|
525
|
+
incompatibility reason. Every provider response is still parsed with the
|
|
526
|
+
original Effect Schema: constrained generation strengthens the trust boundary;
|
|
527
|
+
it never replaces it.
|
|
528
|
+
|
|
529
|
+
Provider credentials, gateway routing, retries, and moderation remain at the
|
|
530
|
+
application composition edge. If a provider is not built in, implement the
|
|
531
|
+
public `StructuredChatModelService` Effect seam; provider-specific SDK types do
|
|
532
|
+
not need to leak into chat, stage, or tool definitions.
|
|
533
|
+
|
|
534
|
+
Built-in provider policy is deliberately conservative:
|
|
535
|
+
|
|
536
|
+
| Provider definition | Tool argument policy |
|
|
537
|
+
| --- | --- |
|
|
538
|
+
| `ModelProvider.cloudflareWorkersAI(...)` | Schema-guided, then parsed and validated |
|
|
539
|
+
| `ModelProvider.openAI(...)` with a recognised Structured Outputs model | Schema-constrained, then parsed and validated |
|
|
540
|
+
| `ModelProvider.openAI(...)` with an unknown model ID | Schema-guided, then parsed and validated |
|
|
541
|
+
|
|
542
|
+
## Connect assistant-ui
|
|
543
|
+
|
|
544
|
+
```tsx
|
|
545
|
+
import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react"
|
|
546
|
+
import { CollectQuestionView } from "@popcomputer/structured-chat"
|
|
547
|
+
import {
|
|
548
|
+
makeAssistantChatModelAdapter,
|
|
549
|
+
makeAssistantView,
|
|
550
|
+
} from "@popcomputer/structured-chat/assistant-ui"
|
|
551
|
+
import type { ReactNode } from "react"
|
|
552
|
+
|
|
553
|
+
const AgencyCardsUI = makeAssistantView(AgencyCards, {
|
|
554
|
+
render: AgencyCardsComponent,
|
|
555
|
+
fallback: InvalidCardFallback,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
const QuestionUI = makeAssistantView(CollectQuestionView, {
|
|
559
|
+
render: ProjectQuestion,
|
|
560
|
+
fallback: InvalidQuestionFallback,
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
const model = makeAssistantChatModelAdapter({
|
|
564
|
+
endpoint: "/api/matchmaker/turn",
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
export function MatchmakerRuntime({
|
|
568
|
+
children,
|
|
569
|
+
}: Readonly<{ children: ReactNode }>) {
|
|
570
|
+
const runtime = useLocalRuntime(model)
|
|
571
|
+
|
|
572
|
+
return (
|
|
573
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
574
|
+
<QuestionUI />
|
|
575
|
+
<AgencyCardsUI />
|
|
576
|
+
{children}
|
|
577
|
+
</AssistantRuntimeProvider>
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
The adapter sends only the current user text and latest opaque session
|
|
583
|
+
reference. It rejects attachments, strictly parses responses, and stores the
|
|
584
|
+
new reference in assistant message metadata. A selected option is simply its
|
|
585
|
+
displayed text; the server-owned stage determines what that text may answer.
|
|
586
|
+
Pass `fetch` only when the application needs a custom transport or test seam.
|
|
587
|
+
|
|
588
|
+
This is the lowest-friction assistant-ui `LocalRuntime` path. Its attachment,
|
|
589
|
+
speech, feedback, suggestion, history, and thread-list adapters remain ordinary
|
|
590
|
+
assistant-ui composition; structured-chat does not replace them. The default
|
|
591
|
+
structured-chat session is linear and optimistically revisioned. Applications
|
|
592
|
+
that expose message editing, regeneration, or persistent branching should pair
|
|
593
|
+
it with an application-owned branch/session policy rather than treating browser
|
|
594
|
+
history as authoritative.
|
|
595
|
+
|
|
596
|
+
## Plan without executing
|
|
597
|
+
|
|
598
|
+
The default remains one call:
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
const result = yield* Matching.run(messages)
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
Approval flows, dry runs, and evals can stop after a strictly parsed proposal:
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
const call = yield* Matching.plan(messages)
|
|
608
|
+
|
|
609
|
+
// Application-owned review or approval.
|
|
610
|
+
const result = yield* Matching.toolSet.execute(call)
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
`plan` uses the same trusted instructions, closed tool set, guards, provider
|
|
614
|
+
adapter, and schemas as `run`; it simply omits application execution.
|
|
615
|
+
`execute` accepts that already parsed call. Use `executeCall(unknownInput)`
|
|
616
|
+
only at a boundary where the call has not already been parsed.
|
|
617
|
+
|
|
618
|
+
## Test transcript scenarios
|
|
619
|
+
|
|
620
|
+
The optional testing entry point scripts valid model calls while exercising the
|
|
621
|
+
real public runtime, schemas, Effects, and session store:
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
import {
|
|
625
|
+
inMemoryChatSessionStore,
|
|
626
|
+
Scenario,
|
|
627
|
+
} from "@popcomputer/structured-chat/testing"
|
|
628
|
+
|
|
629
|
+
const ModelTest = Scenario.model(
|
|
630
|
+
Scenario.answers(ProjectBrief, {
|
|
631
|
+
location: Scenario.quoted("Leeds", { quote: "based in Leeds" }),
|
|
632
|
+
}),
|
|
633
|
+
Scenario.call(SearchAgencies, { query: "Leeds public services" }),
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
const reply = yield* Matchmaker.reply({
|
|
637
|
+
sessionId: "scenario-1",
|
|
638
|
+
message: "We are based in Leeds.",
|
|
639
|
+
}).pipe(
|
|
640
|
+
Effect.provide(Layer.merge(ModelTest, inMemoryChatSessionStore)),
|
|
641
|
+
)
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Scenario values use each schema's Type side and are encoded into provider
|
|
645
|
+
calls, so transformed schemas remain covered. A quote without an index must
|
|
646
|
+
occur in exactly one preceding user message; assistant matches are ignored. If
|
|
647
|
+
the same quote occurs in several user messages, specify
|
|
648
|
+
`{ quote, messageIndex }` and the helper verifies that exact message.
|
|
649
|
+
|
|
650
|
+
Use the DSL for valid conversational flows. Keep malformed envelopes, invalid
|
|
651
|
+
evidence, stale revisions, and history/size limits on raw model and store
|
|
652
|
+
layers—their purpose is to exercise states the helper intentionally refuses to
|
|
653
|
+
construct.
|
|
654
|
+
|
|
655
|
+
After enabling repair, valid correction scripts can use the same evidence
|
|
656
|
+
rules:
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
Scenario.repairs(
|
|
660
|
+
Scenario.replace(ProjectBrief, "location", "Manchester", {
|
|
661
|
+
quote: "Actually, Manchester",
|
|
662
|
+
}),
|
|
663
|
+
)
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
For a field declared with `Answer.confirmed`, use `Scenario.reconfirm(...)` in
|
|
667
|
+
the same corrections list. The helper's type contract prevents replacement of
|
|
668
|
+
confirmed fields and reconfirmation of semantic or explicit fields.
|
|
669
|
+
|
|
670
|
+
## Prompt injection and trust boundaries
|
|
671
|
+
|
|
672
|
+
The framework provides structural containment, not a claim that a prompt can
|
|
673
|
+
make an LLM intrinsically safe.
|
|
674
|
+
|
|
675
|
+
Built-in boundaries include:
|
|
676
|
+
|
|
677
|
+
- trusted stage instructions and untrusted conversation are separate fields;
|
|
678
|
+
- provider adapters serialize conversation under `untrustedConversation` in a
|
|
679
|
+
user message rather than interpolating it into system instructions;
|
|
680
|
+
- each stage exposes a closed tool set, so later capabilities cannot be called
|
|
681
|
+
early;
|
|
682
|
+
- exactly one call is accepted and unknown calls are parsed before execution;
|
|
683
|
+
- excess capability fields are rejected;
|
|
684
|
+
- collection requires exact user-message evidence;
|
|
685
|
+
- confirmed fields cannot be populated before being issued;
|
|
686
|
+
- session state and history are server-owned and revisioned;
|
|
687
|
+
- bounded tool model results used for follow-ups remain untrusted conversation
|
|
688
|
+
context;
|
|
689
|
+
- model, server, and browser result projections are distinct; and
|
|
690
|
+
- optional guards can inspect both the untrusted conversation before the
|
|
691
|
+
model and the strictly parsed proposal before application code executes.
|
|
692
|
+
|
|
693
|
+
Optional guards run before the provider and, when configured, after strict
|
|
694
|
+
call parsing but before application execution. Both hooks can use ordinary
|
|
695
|
+
Effect services:
|
|
696
|
+
|
|
697
|
+
```ts
|
|
698
|
+
const InjectionPolicy = defineModelGuard({
|
|
699
|
+
name: "prompt_injection_policy",
|
|
700
|
+
check: ({ messages, toolNames }) =>
|
|
701
|
+
PolicyService.check({ messages, toolNames }),
|
|
702
|
+
checkCall: ({ messages, call }) =>
|
|
703
|
+
PolicyService.checkCall({ messages, call }),
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
const Matching = Stage.tools({
|
|
707
|
+
name: "matching",
|
|
708
|
+
instructions: ["Route the completed brief to one search."],
|
|
709
|
+
tools: [SearchAgencies],
|
|
710
|
+
guards: [InjectionPolicy],
|
|
711
|
+
})
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
See the complete, strictly type-checked
|
|
715
|
+
[`prompt-injection-policy.ts`](./examples/prompt-injection-policy.ts) example
|
|
716
|
+
for an Effect service whose typed policy rejection flows through the stage.
|
|
717
|
+
|
|
718
|
+
The package intentionally does not ship a universal keyword detector. Prompt
|
|
719
|
+
injection is contextual, and a weak detector can create false confidence. A
|
|
720
|
+
guard may call a specialised classifier, a policy service, or deterministic
|
|
721
|
+
application rules and return the application's typed Effect failure.
|
|
722
|
+
|
|
723
|
+
Applications still own:
|
|
724
|
+
|
|
725
|
+
- authentication, actor-to-session binding, CSRF and origin protection;
|
|
726
|
+
- tool-level authorization and database visibility filters;
|
|
727
|
+
- confirmation for consequential writes;
|
|
728
|
+
- output escaping and safe link/image policies;
|
|
729
|
+
- provider moderation and data-retention choices; and
|
|
730
|
+
- rate limits, spend limits, secrets, network policy, audit retention, and DLP.
|
|
731
|
+
|
|
732
|
+
Those last operational concerns belong in the hosting platform or API gateway,
|
|
733
|
+
not in a chat workflow package.
|
|
734
|
+
|
|
735
|
+
## Versions
|
|
736
|
+
|
|
737
|
+
| Version | Change it when | Effect of changing it |
|
|
738
|
+
| --- | --- | --- |
|
|
739
|
+
| Chat `version` | Persisted stage, answer, provenance, command, or repair state is no longer compatible | Creates a new persistence scope; old sessions are not silently decoded as the new workflow |
|
|
740
|
+
| View `version` | Browser data shape or meaning changes incompatibly | Changes `schemaVersion`; old payloads fail closed in the renderer |
|
|
741
|
+
|
|
742
|
+
Tool inputs and model-result projections are schema-validated but do not have a
|
|
743
|
+
separate numeric version. Make incompatible tool changes deliberately: rename
|
|
744
|
+
the tool or coordinate provider, application, and eval changes together.
|
|
745
|
+
|
|
746
|
+
## Design trade-offs
|
|
747
|
+
|
|
748
|
+
- Workflows are sequential collect stages followed by one query-tool or command
|
|
749
|
+
stage. Query stages remain active for follow-ups unless explicitly terminal;
|
|
750
|
+
command stages are always terminal. Arbitrary cyclic agent graphs are
|
|
751
|
+
intentionally out of scope.
|
|
752
|
+
- Every model request produces exactly one closed, schema-parsed call. One
|
|
753
|
+
contract-invalid response may receive a bounded repair request before any
|
|
754
|
+
application tool executes. If collection still receives invalid or
|
|
755
|
+
ungrounded model output — including evidence quotes that do not appear
|
|
756
|
+
verbatim in a user message — it safely presents the application-authored
|
|
757
|
+
pending question without advancing answers. A turn
|
|
758
|
+
may advance from collection into its next sequential stage. Opt-in repair
|
|
759
|
+
adds one specifically bounded planner step on a final-stage correction: one
|
|
760
|
+
repair call may be followed by one collect/query call. There is no unbounded
|
|
761
|
+
model → tool → model loop.
|
|
762
|
+
- Tool and command execution are application-owned. Commands make idempotency
|
|
763
|
+
identity explicit; the framework does not infer authorization or provide the
|
|
764
|
+
application's durable outcome journal.
|
|
765
|
+
- Durable persistence requires an adapter; no database is selected by default.
|
|
766
|
+
- Public errors expose only stable, content-free reasons and identifiers
|
|
767
|
+
relevant to each failure—not raw prompts, provider responses, persisted
|
|
768
|
+
state, or unknown causes. Content-free Effect spans cover model
|
|
769
|
+
requests and session-store loads/replacements, with aggregate message and
|
|
770
|
+
character counts where available. They never include session identifiers,
|
|
771
|
+
namespaces, revisions, message content, or tool arguments/results.
|
|
772
|
+
- assistant-ui and React are optional peer integrations. The core has no React
|
|
773
|
+
runtime dependency.
|
|
774
|
+
- The default browser adapter is non-streaming and uses one linear server
|
|
775
|
+
revision. Streaming and branch-aware thread restoration require explicit
|
|
776
|
+
application protocols rather than hidden framework behaviour.
|
|
777
|
+
|
|
778
|
+
These constraints keep the default path legible while leaving provider,
|
|
779
|
+
persistence, guards, tools, views, and Effect services independently
|
|
780
|
+
composable.
|