@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
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { Effect, Schema, unsafeCoerce } from "effect";
|
|
2
|
+
import { readCollectStageRuntime } from "./collect-stage.js";
|
|
3
|
+
import { countUntrustedMessageCharacters, UntrustedMessageSchema, } from "./model.js";
|
|
4
|
+
import { readCommandStageRuntime, readToolStageRuntime, } from "./stage.js";
|
|
5
|
+
import { readToolExecutionModelContext } from "./tool.js";
|
|
6
|
+
import { deriveCommandId } from "./command.js";
|
|
7
|
+
import { defineTool } from "./tool.js";
|
|
8
|
+
import { ChatSessionConflict, ChatSessionIdSchema, ChatSessionNamespaceSchema, ChatSessionReplacementSchema, ChatSessionRevisionSchema, ChatSessionSnapshotSchema, ChatSessionStore, InvalidChatSession, } from "./session.js";
|
|
9
|
+
/** Stable machine-facing name for one structured chat definition. */
|
|
10
|
+
export const ChatNameSchema = Schema.NonEmptyTrimmedString.pipe(Schema.maxLength(100), Schema.pattern(/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/));
|
|
11
|
+
/** Positive persisted-state version for one structured chat definition. */
|
|
12
|
+
export const ChatVersionSchema = Schema.Number.pipe(Schema.int(), Schema.between(1, 2_147_483_647));
|
|
13
|
+
/** Safe reason that a server-owned chat transition was rejected. */
|
|
14
|
+
export const InvalidChatTransitionReasonSchema = Schema.Literal("already_complete", "invalid_state");
|
|
15
|
+
/** A server-owned chat state cannot perform the requested transition. */
|
|
16
|
+
export class InvalidChatTransition extends Schema.TaggedError()("InvalidChatTransition", {
|
|
17
|
+
chat: ChatNameSchema,
|
|
18
|
+
reason: InvalidChatTransitionReasonSchema,
|
|
19
|
+
}) {
|
|
20
|
+
}
|
|
21
|
+
const invalidTransition = (chat, reason) => new InvalidChatTransition({ chat, reason });
|
|
22
|
+
const invalidSession = (reason) => new InvalidChatSession({ reason });
|
|
23
|
+
const ChatReplyBoundaryInputSchema = Schema.Struct({
|
|
24
|
+
namespace: Schema.optional(ChatSessionNamespaceSchema),
|
|
25
|
+
sessionId: ChatSessionIdSchema,
|
|
26
|
+
expectedRevision: Schema.optional(ChatSessionRevisionSchema),
|
|
27
|
+
message: UntrustedMessageSchema.fields.content,
|
|
28
|
+
});
|
|
29
|
+
const maximumPersistedMessages = 200;
|
|
30
|
+
const maximumMessagesAddedPerTurn = 2;
|
|
31
|
+
/**
|
|
32
|
+
* Define one sequential chat with collectors and one final executable stage.
|
|
33
|
+
*
|
|
34
|
+
* A final query stage remains active by default so later user messages can
|
|
35
|
+
* refine results. A final command stage is always terminal.
|
|
36
|
+
*/
|
|
37
|
+
export const defineChat = (definition) => {
|
|
38
|
+
Schema.decodeSync(ChatNameSchema)(definition.name);
|
|
39
|
+
Schema.decodeSync(ChatVersionSchema)(definition.version);
|
|
40
|
+
const names = definition.stages.map(({ name }) => name);
|
|
41
|
+
if (new Set(names).size !== names.length) {
|
|
42
|
+
throw new Error("Structured chat stage names must be unique");
|
|
43
|
+
}
|
|
44
|
+
const finalStage = definition.stages.at(-1);
|
|
45
|
+
if (finalStage?._tag !== "ToolStage" &&
|
|
46
|
+
finalStage?._tag !== "CommandStage") {
|
|
47
|
+
throw new Error("Structured chats require one final tool or command stage");
|
|
48
|
+
}
|
|
49
|
+
if (definition.stages
|
|
50
|
+
.slice(0, -1)
|
|
51
|
+
.some((stage) => stage._tag !== "CollectStage")) {
|
|
52
|
+
throw new Error("Only collect stages may precede the final executable stage");
|
|
53
|
+
}
|
|
54
|
+
const repair = definition.repair;
|
|
55
|
+
if (repair !== undefined &&
|
|
56
|
+
(finalStage?._tag !== "ToolStage" ||
|
|
57
|
+
readToolStageRuntime(finalStage).afterExecution !== "stay")) {
|
|
58
|
+
throw new Error("Conversation repair requires a repeatable final query stage");
|
|
59
|
+
}
|
|
60
|
+
const stateFields = {};
|
|
61
|
+
const initialStages = {};
|
|
62
|
+
const collectStages = [];
|
|
63
|
+
for (const [index, stage] of definition.stages.entries()) {
|
|
64
|
+
if (stage._tag !== "CollectStage") {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const runtime = readCollectStageRuntime(stage);
|
|
68
|
+
stateFields[stage.name] = runtime.stateSchema;
|
|
69
|
+
initialStages[stage.name] = runtime.initialState;
|
|
70
|
+
collectStages.push({ index, stage, runtime });
|
|
71
|
+
}
|
|
72
|
+
const finalStageIndex = definition.stages.length - 1;
|
|
73
|
+
if (repair !== undefined && collectStages.length === 0) {
|
|
74
|
+
throw new Error("Conversation repair requires at least one collect stage");
|
|
75
|
+
}
|
|
76
|
+
const repairToolName = "apply_conversation_repairs";
|
|
77
|
+
if (repair !== undefined &&
|
|
78
|
+
finalStage?._tag === "ToolStage" &&
|
|
79
|
+
readToolStageRuntime(finalStage).toolNames.includes(repairToolName)) {
|
|
80
|
+
throw new Error(`Tool name is reserved for repair: ${repairToolName}`);
|
|
81
|
+
}
|
|
82
|
+
const repairTool = (() => {
|
|
83
|
+
if (repair === undefined) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const schemas = collectStages.map(({ runtime }) => runtime.repairSchema);
|
|
87
|
+
const [firstSchema, ...remainingSchemas] = schemas;
|
|
88
|
+
if (firstSchema === undefined) {
|
|
89
|
+
throw new Error("Conversation repair requires correction schemas");
|
|
90
|
+
}
|
|
91
|
+
const correctionSchema = remainingSchemas.length === 0
|
|
92
|
+
? firstSchema
|
|
93
|
+
: Schema.Union(firstSchema, ...remainingSchemas);
|
|
94
|
+
const input = Schema.Struct({
|
|
95
|
+
corrections: Schema.NonEmptyArray(correctionSchema).pipe(Schema.maxItems(repair.maximumCorrections)),
|
|
96
|
+
});
|
|
97
|
+
return defineTool({
|
|
98
|
+
name: repairToolName,
|
|
99
|
+
description: "Use only when the latest user message explicitly corrects previously accepted facts. Quote evidence from that latest user message. Replace semantic or explicit answers; request reconfirmation for confirmed answers.",
|
|
100
|
+
input,
|
|
101
|
+
execute: (proposal) => Effect.succeed(proposal),
|
|
102
|
+
});
|
|
103
|
+
})();
|
|
104
|
+
const isValidRuntimeState = (state) => {
|
|
105
|
+
const pendingStages = state.repair?.pendingStages ?? [];
|
|
106
|
+
if ((repair === undefined && state.repair !== undefined) ||
|
|
107
|
+
(repair !== undefined && state.repair === undefined)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (pendingStages.some((index, position) => definition.stages[index]?._tag !== "CollectStage" ||
|
|
111
|
+
(position > 0 && index <= (pendingStages[position - 1] ?? -1)))) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
if (pendingStages.length > 0) {
|
|
115
|
+
if (state.status !== "active" ||
|
|
116
|
+
state.stage !== pendingStages[0]) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
for (const { index, stage, runtime } of collectStages) {
|
|
120
|
+
const stageState = state.stages[stage.name];
|
|
121
|
+
if (stageState === undefined || !runtime.isValid(stageState)) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const pending = pendingStages.includes(index);
|
|
125
|
+
if (pending === runtime.isComplete(stageState)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (state.status === "complete") {
|
|
132
|
+
if (state.stage !== finalStageIndex || finalStage === undefined) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if ((finalStage._tag === "ToolStage" &&
|
|
136
|
+
readToolStageRuntime(finalStage).afterExecution !== "complete")) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (let index = 0; index < definition.stages.length; index += 1) {
|
|
141
|
+
const stage = definition.stages[index];
|
|
142
|
+
if (stage?._tag !== "CollectStage") {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const runtime = readCollectStageRuntime(stage);
|
|
146
|
+
const stageState = state.stages[stage.name];
|
|
147
|
+
if (stageState === undefined || !runtime.isValid(stageState)) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (index < state.stage && !runtime.isComplete(stageState)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (state.status === "active" &&
|
|
154
|
+
index === state.stage &&
|
|
155
|
+
runtime.isComplete(stageState)) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (index > state.stage && !runtime.isInitial(stageState)) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
};
|
|
164
|
+
const baseStateFields = {
|
|
165
|
+
schemaVersion: Schema.Literal(definition.version),
|
|
166
|
+
chat: Schema.Literal(definition.name),
|
|
167
|
+
stage: Schema.Number.pipe(Schema.int(), Schema.between(0, definition.stages.length - 1)),
|
|
168
|
+
status: Schema.Literal("active", "complete"),
|
|
169
|
+
stages: Schema.Struct(stateFields),
|
|
170
|
+
};
|
|
171
|
+
const rawStateSchema = repair === undefined
|
|
172
|
+
? Schema.Struct(baseStateFields)
|
|
173
|
+
: Schema.Struct({
|
|
174
|
+
...baseStateFields,
|
|
175
|
+
repair: Schema.Struct({
|
|
176
|
+
pendingStages: Schema.Array(Schema.Number.pipe(Schema.int(), Schema.between(0, finalStageIndex - 1))).pipe(Schema.maxItems(20)),
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
// SAFETY: the conditional repair field is erased only for applying the
|
|
180
|
+
// shared semantic predicate; stateSchema below restores the public type.
|
|
181
|
+
const runtimeStateSchema = unsafeCoerce(rawStateSchema);
|
|
182
|
+
const refinedStateSchema = runtimeStateSchema.pipe(Schema.filter((state) => isValidRuntimeState(unsafeCoerce(state)), {
|
|
183
|
+
description: "semantically valid structured-chat state",
|
|
184
|
+
}));
|
|
185
|
+
// SAFETY: stage state fields are taken directly from the concrete collect
|
|
186
|
+
// stages, and the remaining envelope fields are exact literals or bounds.
|
|
187
|
+
const stateSchema = unsafeCoerce(refinedStateSchema);
|
|
188
|
+
const baseInitialState = {
|
|
189
|
+
schemaVersion: definition.version,
|
|
190
|
+
chat: definition.name,
|
|
191
|
+
stage: 0,
|
|
192
|
+
status: "active",
|
|
193
|
+
stages: initialStages,
|
|
194
|
+
};
|
|
195
|
+
const initialStateInput = repair === undefined
|
|
196
|
+
? baseInitialState
|
|
197
|
+
: { ...baseInitialState, repair: { pendingStages: [] } };
|
|
198
|
+
const initialState = Schema.validateSync(stateSchema)(initialStateInput);
|
|
199
|
+
const isGroundedInMessages = (state, messages) => definition.stages.every((stage) => {
|
|
200
|
+
if (stage._tag !== "CollectStage") {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const stageState = state.stages[stage.name];
|
|
204
|
+
return (stageState !== undefined &&
|
|
205
|
+
readCollectStageRuntime(stage).isGroundedInMessages(stageState, messages));
|
|
206
|
+
});
|
|
207
|
+
const applyConversationRepairs = (state, messages, corrections) => Effect.gen(function* () {
|
|
208
|
+
const grouped = new Map();
|
|
209
|
+
for (const correction of corrections) {
|
|
210
|
+
const current = grouped.get(correction.stage) ?? [];
|
|
211
|
+
current.push(correction);
|
|
212
|
+
grouped.set(correction.stage, current);
|
|
213
|
+
}
|
|
214
|
+
let stages = { ...state.stages };
|
|
215
|
+
const pendingStages = [];
|
|
216
|
+
for (const { index, stage, runtime } of collectStages) {
|
|
217
|
+
const stageRepairs = grouped.get(stage.name);
|
|
218
|
+
if (stageRepairs === undefined) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
grouped.delete(stage.name);
|
|
222
|
+
const stageState = stages[stage.name];
|
|
223
|
+
if (stageState === undefined) {
|
|
224
|
+
return yield* Effect.fail(invalidTransition(definition.name, "invalid_state"));
|
|
225
|
+
}
|
|
226
|
+
const result = yield* runtime.applyRepairs(stageState, messages, stageRepairs);
|
|
227
|
+
stages = { ...stages, [stage.name]: result.state };
|
|
228
|
+
if (result.requiresConfirmation) {
|
|
229
|
+
pendingStages.push(index);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (grouped.size > 0) {
|
|
233
|
+
return yield* Effect.fail(invalidTransition(definition.name, "invalid_state"));
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
...state,
|
|
237
|
+
stage: pendingStages[0] ?? finalStageIndex,
|
|
238
|
+
stages,
|
|
239
|
+
repair: { pendingStages },
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
/** Dispatch only values already checked against this exact transcript. */
|
|
243
|
+
const runTrustedRuntime = (state, messages, commandContext, allowRepair = false) => {
|
|
244
|
+
if (state.status === "complete") {
|
|
245
|
+
return Effect.fail(invalidTransition(definition.name, "already_complete"));
|
|
246
|
+
}
|
|
247
|
+
const stage = definition.stages[state.stage];
|
|
248
|
+
if (stage === undefined) {
|
|
249
|
+
return Effect.fail(invalidTransition(definition.name, "invalid_state"));
|
|
250
|
+
}
|
|
251
|
+
if (stage._tag === "CommandStage") {
|
|
252
|
+
if (commandContext === undefined) {
|
|
253
|
+
return Effect.fail(invalidTransition(definition.name, "invalid_state"));
|
|
254
|
+
}
|
|
255
|
+
return readCommandStageRuntime(stage)
|
|
256
|
+
.run(messages, commandContext)
|
|
257
|
+
.pipe(Effect.map((result) => ({
|
|
258
|
+
_tag: "Complete",
|
|
259
|
+
stage: stage.name,
|
|
260
|
+
state: {
|
|
261
|
+
...state,
|
|
262
|
+
status: "complete",
|
|
263
|
+
},
|
|
264
|
+
result,
|
|
265
|
+
})));
|
|
266
|
+
}
|
|
267
|
+
if (stage._tag === "ToolStage") {
|
|
268
|
+
const runtime = readToolStageRuntime(stage);
|
|
269
|
+
if (allowRepair && repairTool !== undefined) {
|
|
270
|
+
return runtime.planWith(messages, repairTool).pipe(Effect.flatMap((planned) => {
|
|
271
|
+
const call = planned;
|
|
272
|
+
if (call.name !== repairToolName) {
|
|
273
|
+
return runtime.execute(call).pipe(Effect.map((result) => ({
|
|
274
|
+
_tag: "ToolResult",
|
|
275
|
+
stage: stage.name,
|
|
276
|
+
state,
|
|
277
|
+
result,
|
|
278
|
+
})));
|
|
279
|
+
}
|
|
280
|
+
// SAFETY: planWith used the generated repair tool schema, and this
|
|
281
|
+
// branch is selected by that tool's unique literal name.
|
|
282
|
+
const proposal = unsafeCoerce(call.arguments);
|
|
283
|
+
return applyConversationRepairs(state, messages, proposal.corrections).pipe(Effect.flatMap((repairedState) => Effect.suspend(() => runTrustedRuntime(repairedState, messages, commandContext, false))));
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
return runtime.run(messages).pipe(Effect.map((result) => runtime.afterExecution === "complete"
|
|
287
|
+
? {
|
|
288
|
+
_tag: "Complete",
|
|
289
|
+
stage: stage.name,
|
|
290
|
+
state: {
|
|
291
|
+
...state,
|
|
292
|
+
status: "complete",
|
|
293
|
+
},
|
|
294
|
+
result,
|
|
295
|
+
}
|
|
296
|
+
: {
|
|
297
|
+
_tag: "ToolResult",
|
|
298
|
+
stage: stage.name,
|
|
299
|
+
state,
|
|
300
|
+
result,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
const runtime = readCollectStageRuntime(stage);
|
|
304
|
+
const collectState = state.stages[stage.name];
|
|
305
|
+
if (collectState === undefined) {
|
|
306
|
+
return Effect.fail(invalidTransition(definition.name, "invalid_state"));
|
|
307
|
+
}
|
|
308
|
+
return runtime
|
|
309
|
+
.run({ state: collectState, messages })
|
|
310
|
+
.pipe(Effect.flatMap((turn) => {
|
|
311
|
+
const nextState = {
|
|
312
|
+
...state,
|
|
313
|
+
stages: {
|
|
314
|
+
...state.stages,
|
|
315
|
+
[stage.name]: turn.state,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
if (turn.complete) {
|
|
319
|
+
const pendingStages = state.repair?.pendingStages ?? [];
|
|
320
|
+
const remainingPending = pendingStages[0] === state.stage
|
|
321
|
+
? pendingStages.slice(1)
|
|
322
|
+
: pendingStages;
|
|
323
|
+
const nextStage = pendingStages.length > 0
|
|
324
|
+
? (remainingPending[0] ?? finalStageIndex)
|
|
325
|
+
: state.stage + 1;
|
|
326
|
+
const advancedState = state.repair === undefined
|
|
327
|
+
? { ...nextState, stage: nextStage }
|
|
328
|
+
: {
|
|
329
|
+
...nextState,
|
|
330
|
+
stage: nextStage,
|
|
331
|
+
repair: { pendingStages: remainingPending },
|
|
332
|
+
};
|
|
333
|
+
return Effect.suspend(() => runTrustedRuntime(advancedState, messages, commandContext, false));
|
|
334
|
+
}
|
|
335
|
+
return Effect.succeed({
|
|
336
|
+
_tag: "Question",
|
|
337
|
+
stage: stage.name,
|
|
338
|
+
state: nextState,
|
|
339
|
+
question: turn.question,
|
|
340
|
+
});
|
|
341
|
+
}));
|
|
342
|
+
};
|
|
343
|
+
/** Validate a public runtime entry before entering trusted transitions. */
|
|
344
|
+
const runCheckedRuntime = (state, messages, commandContext, allowRepair = false) => !isValidRuntimeState(state) ||
|
|
345
|
+
!isGroundedInMessages(state, messages)
|
|
346
|
+
? Effect.fail(invalidTransition(definition.name, "invalid_state"))
|
|
347
|
+
: runTrustedRuntime(state, messages, commandContext, allowRepair);
|
|
348
|
+
const run = (input) => {
|
|
349
|
+
// SAFETY: ChatState is generated from the same stage tuple as the sealed
|
|
350
|
+
// runtime state contract; only generic correlations are erased here.
|
|
351
|
+
const runtimeState = unsafeCoerce(input.state);
|
|
352
|
+
const runtime = runCheckedRuntime(runtimeState, input.messages);
|
|
353
|
+
// SAFETY: runtime dispatch follows the exact Stages tuple and each stage
|
|
354
|
+
// retains its own parsing, errors, dependencies, and output constructor.
|
|
355
|
+
return unsafeCoerce(runtime);
|
|
356
|
+
};
|
|
357
|
+
const reply = (input) => Effect.gen(function* () {
|
|
358
|
+
const parsedInput = yield* Schema.decodeUnknown(ChatReplyBoundaryInputSchema)(input, { onExcessProperty: "error" }).pipe(Effect.mapError(() => invalidSession("invalid_input")));
|
|
359
|
+
const store = yield* ChatSessionStore;
|
|
360
|
+
const scope = {
|
|
361
|
+
namespace: parsedInput.namespace ?? "",
|
|
362
|
+
sessionId: parsedInput.sessionId,
|
|
363
|
+
chat: definition.name,
|
|
364
|
+
version: definition.version,
|
|
365
|
+
};
|
|
366
|
+
const loaded = yield* store.load(scope).pipe(Effect.withSpan("popcomputer.structured_chat.session.load", {
|
|
367
|
+
attributes: {
|
|
368
|
+
chat: definition.name,
|
|
369
|
+
version: definition.version,
|
|
370
|
+
},
|
|
371
|
+
}));
|
|
372
|
+
const snapshot = loaded === null
|
|
373
|
+
? null
|
|
374
|
+
: yield* Schema.decodeUnknown(ChatSessionSnapshotSchema)(loaded, { onExcessProperty: "error" }).pipe(Effect.mapError(() => invalidSession("invalid_snapshot")));
|
|
375
|
+
if ((snapshot === null &&
|
|
376
|
+
parsedInput.expectedRevision !== undefined) ||
|
|
377
|
+
(snapshot !== null &&
|
|
378
|
+
parsedInput.expectedRevision !== snapshot.revision)) {
|
|
379
|
+
return yield* Effect.fail(new ChatSessionConflict({ reason: "concurrent_update" }));
|
|
380
|
+
}
|
|
381
|
+
const state = snapshot === null
|
|
382
|
+
? initialState
|
|
383
|
+
: yield* Schema.decodeUnknown(stateSchema)(snapshot.state, {
|
|
384
|
+
onExcessProperty: "error",
|
|
385
|
+
}).pipe(Effect.mapError(() => invalidSession("invalid_state")));
|
|
386
|
+
const previousMessages = snapshot?.messages ?? [];
|
|
387
|
+
// SAFETY: stateSchema decoded this definition's exact state envelope.
|
|
388
|
+
const runtimeState = unsafeCoerce(state);
|
|
389
|
+
if (!isGroundedInMessages(runtimeState, previousMessages)) {
|
|
390
|
+
return yield* Effect.fail(invalidSession("invalid_state"));
|
|
391
|
+
}
|
|
392
|
+
if (previousMessages.length + maximumMessagesAddedPerTurn >
|
|
393
|
+
maximumPersistedMessages) {
|
|
394
|
+
return yield* Effect.fail(invalidSession("history_limit"));
|
|
395
|
+
}
|
|
396
|
+
const userMessage = yield* Schema.decodeUnknown(UntrustedMessageSchema)({ role: "user", content: parsedInput.message }, { onExcessProperty: "error" }).pipe(Effect.mapError(() => invalidSession("invalid_input")));
|
|
397
|
+
const messages = [...previousMessages, userMessage];
|
|
398
|
+
const commandContext = finalStage?._tag === "CommandStage"
|
|
399
|
+
? {
|
|
400
|
+
commandId: yield* deriveCommandId({
|
|
401
|
+
namespace: scope.namespace,
|
|
402
|
+
chat: definition.name,
|
|
403
|
+
version: definition.version,
|
|
404
|
+
sessionId: scope.sessionId,
|
|
405
|
+
expectedRevision: snapshot?.revision ?? null,
|
|
406
|
+
command: readCommandStageRuntime(finalStage).commandName,
|
|
407
|
+
}),
|
|
408
|
+
}
|
|
409
|
+
: undefined;
|
|
410
|
+
// SAFETY: stateSchema has parsed the definition-owned state and the
|
|
411
|
+
// explicit check above grounded it against these exact messages.
|
|
412
|
+
// Reply supplies command identity only to the active terminal command.
|
|
413
|
+
const trustedTurn = runTrustedRuntime(runtimeState, messages, commandContext, repair !== undefined &&
|
|
414
|
+
snapshot !== null &&
|
|
415
|
+
state.status === "active" &&
|
|
416
|
+
state.stage === finalStageIndex);
|
|
417
|
+
const turn = yield* unsafeCoerce(trustedTurn);
|
|
418
|
+
const toolModelContext = turn._tag === "Question"
|
|
419
|
+
? undefined
|
|
420
|
+
: readToolExecutionModelContext(turn.result);
|
|
421
|
+
const persistedMessages = turn._tag === "Question"
|
|
422
|
+
? [
|
|
423
|
+
...messages,
|
|
424
|
+
{
|
|
425
|
+
role: "assistant",
|
|
426
|
+
content: turn.question.text,
|
|
427
|
+
},
|
|
428
|
+
]
|
|
429
|
+
: toolModelContext === undefined
|
|
430
|
+
? messages
|
|
431
|
+
: [
|
|
432
|
+
...messages,
|
|
433
|
+
{
|
|
434
|
+
role: "assistant",
|
|
435
|
+
content: toolModelContext,
|
|
436
|
+
},
|
|
437
|
+
];
|
|
438
|
+
if (persistedMessages.length > maximumPersistedMessages) {
|
|
439
|
+
return yield* Effect.fail(invalidSession("history_limit"));
|
|
440
|
+
}
|
|
441
|
+
const encodedState = yield* Schema.encodeUnknown(stateSchema)(turn.state, { onExcessProperty: "error" }).pipe(Effect.mapError(() => invalidSession("invalid_state")));
|
|
442
|
+
const replaced = yield* store
|
|
443
|
+
.replace({
|
|
444
|
+
...scope,
|
|
445
|
+
expectedRevision: snapshot?.revision ?? null,
|
|
446
|
+
state: encodedState,
|
|
447
|
+
messages: persistedMessages,
|
|
448
|
+
})
|
|
449
|
+
.pipe(Effect.withSpan("popcomputer.structured_chat.session.replace", {
|
|
450
|
+
attributes: {
|
|
451
|
+
chat: definition.name,
|
|
452
|
+
version: definition.version,
|
|
453
|
+
messageCount: persistedMessages.length,
|
|
454
|
+
messageCharacterCount: countUntrustedMessageCharacters(persistedMessages),
|
|
455
|
+
stage: turn.state.stage,
|
|
456
|
+
status: turn.state.status,
|
|
457
|
+
},
|
|
458
|
+
}));
|
|
459
|
+
const replacement = yield* Schema.decodeUnknown(ChatSessionReplacementSchema)(replaced, { onExcessProperty: "error" }).pipe(Effect.mapError(() => invalidSession("invalid_replacement")));
|
|
460
|
+
return {
|
|
461
|
+
revision: replacement.revision,
|
|
462
|
+
turn,
|
|
463
|
+
};
|
|
464
|
+
}).pipe(Effect.withSpan("popcomputer.structured_chat.session.reply", {
|
|
465
|
+
attributes: { chat: definition.name },
|
|
466
|
+
}));
|
|
467
|
+
return {
|
|
468
|
+
name: definition.name,
|
|
469
|
+
version: definition.version,
|
|
470
|
+
stages: definition.stages,
|
|
471
|
+
repair,
|
|
472
|
+
stateSchema,
|
|
473
|
+
initialState,
|
|
474
|
+
getAcceptedAnswer: (state, stage, field) => {
|
|
475
|
+
if (!definition.stages.includes(stage)) {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
// SAFETY: Stage is restricted to this chat's concrete collect stages,
|
|
479
|
+
// Field is restricted to its field keys, and state uses the same tuple.
|
|
480
|
+
const runtimeState = unsafeCoerce(state);
|
|
481
|
+
const accepted = runtimeState.stages[stage.name]?.accepted[field];
|
|
482
|
+
return unsafeCoerce(accepted);
|
|
483
|
+
},
|
|
484
|
+
parseState: (input) => Schema.decodeUnknown(stateSchema)(input, {
|
|
485
|
+
onExcessProperty: "error",
|
|
486
|
+
}),
|
|
487
|
+
run,
|
|
488
|
+
reply,
|
|
489
|
+
};
|
|
490
|
+
};
|