@fungi.computer/shiitake-extensions 0.0.0-stage → 0.0.1
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 +21 -0
- package/README.md +262 -2
- package/dist/almanac/contract.d.ts +213 -0
- package/dist/almanac/contract.js +77 -0
- package/dist/almanac/host.d.ts +3 -0
- package/dist/almanac/host.js +171 -0
- package/dist/goal/client.d.ts +10 -0
- package/dist/goal/client.js +88 -0
- package/dist/goal/contract.d.ts +172 -0
- package/dist/goal/contract.js +76 -0
- package/dist/goal/host.d.ts +5 -0
- package/dist/goal/host.js +542 -0
- package/dist/home/contract.d.ts +33 -0
- package/dist/home/contract.js +25 -0
- package/dist/home/host.d.ts +18 -0
- package/dist/home/host.js +136 -0
- package/dist/home/routing.d.ts +3 -0
- package/dist/home/routing.js +80 -0
- package/dist/internal/catalog.d.ts +20 -0
- package/dist/internal/catalog.js +33 -0
- package/dist/internal/cron.d.ts +5 -0
- package/dist/internal/cron.js +14 -0
- package/dist/internal/history.d.ts +29 -0
- package/dist/internal/history.js +41 -0
- package/dist/loop/client.d.ts +10 -0
- package/dist/loop/client.js +89 -0
- package/dist/loop/contract.d.ts +342 -0
- package/dist/loop/contract.js +94 -0
- package/dist/loop/host.d.ts +4 -0
- package/dist/loop/host.js +637 -0
- package/dist/plan/client.d.ts +13 -0
- package/dist/plan/client.js +51 -0
- package/dist/plan/contract.d.ts +105 -0
- package/dist/plan/contract.js +38 -0
- package/dist/plan/host.d.ts +3 -0
- package/dist/plan/host.js +154 -0
- package/dist/question/client.d.ts +10 -0
- package/dist/question/client.js +28 -0
- package/dist/question/contract.d.ts +251 -0
- package/dist/question/contract.js +114 -0
- package/dist/question/host.d.ts +3 -0
- package/dist/question/host.js +997 -0
- package/dist/roster.d.ts +9 -0
- package/dist/roster.js +80 -0
- package/dist/skills/host.d.ts +7 -0
- package/dist/skills/host.js +223 -0
- package/dist/skills/source.d.ts +28 -0
- package/dist/skills/source.js +1 -0
- package/dist/subagents/client.d.ts +10 -0
- package/dist/subagents/client.js +53 -0
- package/dist/subagents/contract.d.ts +54 -0
- package/dist/subagents/contract.js +38 -0
- package/dist/subagents/host.d.ts +50 -0
- package/dist/subagents/host.js +176 -0
- package/package.json +117 -3
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
import { nanoid } from "@fungi.computer/cairn";
|
|
2
|
+
import { Mycelium } from "@fungi.computer/mycelium";
|
|
3
|
+
import { defineCollection, defineExtension, ExtensionCommandError, } from "@fungi.computer/shiitake/extensions";
|
|
4
|
+
import * as Clock from "effect/Clock";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as Option from "effect/Option";
|
|
7
|
+
import * as Schema from "effect/Schema";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import Papa from "papaparse";
|
|
11
|
+
import { ask, catalog, done, freshRequestId } from "../internal/catalog.js";
|
|
12
|
+
import { questionContract, OpenAsk, Questions, Answers, HumanResult, DeclineInput, ClosedStatus, AnswerFlowInput, } from "./contract.js";
|
|
13
|
+
const config = {
|
|
14
|
+
maximumOpen: 4,
|
|
15
|
+
liveWindowMs: 600_000,
|
|
16
|
+
guide: "Use question.ask for decisions that need a person's answer. Put the recommended option first; do not add Other, since free text is always available. Use credential tools for secrets. While a question is parked, finish independent work and end the turn.",
|
|
17
|
+
};
|
|
18
|
+
const Response = Schema.Union([
|
|
19
|
+
Schema.Struct({ status: Schema.Literal("answered"), answers: Answers }),
|
|
20
|
+
Schema.Struct({
|
|
21
|
+
status: Schema.Literal("declined"),
|
|
22
|
+
note: DeclineInput.fields.note,
|
|
23
|
+
}),
|
|
24
|
+
Schema.Struct({
|
|
25
|
+
status: Schema.Literal("expired"),
|
|
26
|
+
action: Schema.Literal("stop"),
|
|
27
|
+
}),
|
|
28
|
+
Schema.Struct({
|
|
29
|
+
status: Schema.Literal("expired"),
|
|
30
|
+
action: Schema.Literal("proceed_with_recommended"),
|
|
31
|
+
answers: Answers,
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
const AskRow = Schema.Struct({
|
|
35
|
+
askId: OpenAsk.fields.askId,
|
|
36
|
+
questions: Questions,
|
|
37
|
+
status: OpenAsk.fields.status,
|
|
38
|
+
createdAt: OpenAsk.fields.createdAt,
|
|
39
|
+
expiry: Schema.NullOr(Schema.Struct({
|
|
40
|
+
at: Schema.Number,
|
|
41
|
+
action: Schema.Literals(["stop", "proceed_with_recommended"]),
|
|
42
|
+
})),
|
|
43
|
+
sessionId: Schema.String,
|
|
44
|
+
workId: Schema.String,
|
|
45
|
+
digest: Schema.String,
|
|
46
|
+
runAttemptId: Schema.NullOr(Schema.String),
|
|
47
|
+
pending: Schema.NullOr(Response),
|
|
48
|
+
});
|
|
49
|
+
const Outcome = Schema.Union([
|
|
50
|
+
Response,
|
|
51
|
+
Schema.Struct({ status: Schema.Literal("withdrawn") }),
|
|
52
|
+
Schema.Struct({ status: Schema.Literal("cancelled") }),
|
|
53
|
+
Schema.Struct({
|
|
54
|
+
status: Schema.Literal("undeliverable"),
|
|
55
|
+
reason: Schema.String,
|
|
56
|
+
}),
|
|
57
|
+
]);
|
|
58
|
+
const ClosedAsk = Schema.Struct({
|
|
59
|
+
...AskRow.fields,
|
|
60
|
+
status: Schema.Literal("settled"),
|
|
61
|
+
outcome: Outcome,
|
|
62
|
+
replay: Schema.Literals(["parked", "outcome", "none"]),
|
|
63
|
+
delivery: Schema.Union([
|
|
64
|
+
Schema.Struct({ via: Schema.Literal("none") }),
|
|
65
|
+
Schema.Struct({
|
|
66
|
+
via: Schema.Literal("in_turn"),
|
|
67
|
+
status: Schema.Literals(["pending", "settled"]),
|
|
68
|
+
}),
|
|
69
|
+
Schema.Struct({
|
|
70
|
+
via: Schema.Literal("follow_up"),
|
|
71
|
+
workId: Schema.String,
|
|
72
|
+
status: Schema.Literals(["pending", "settled", "withdrawn"]),
|
|
73
|
+
}),
|
|
74
|
+
]),
|
|
75
|
+
});
|
|
76
|
+
const Receipt = Schema.Struct({
|
|
77
|
+
id: Schema.String,
|
|
78
|
+
digest: Schema.String,
|
|
79
|
+
sessionId: Schema.String,
|
|
80
|
+
requestId: Schema.String,
|
|
81
|
+
payload: Schema.String,
|
|
82
|
+
result: HumanResult,
|
|
83
|
+
});
|
|
84
|
+
const collections = {
|
|
85
|
+
deliveries: defineCollection(Schema.Struct({
|
|
86
|
+
id: Schema.String,
|
|
87
|
+
workId: Schema.String,
|
|
88
|
+
askId: Schema.String,
|
|
89
|
+
toolCallId: Schema.String,
|
|
90
|
+
}), {
|
|
91
|
+
key: (row) => row.id,
|
|
92
|
+
index: (row) => row.workId,
|
|
93
|
+
}),
|
|
94
|
+
closed: defineCollection(ClosedAsk, {
|
|
95
|
+
key: (row) => row.askId,
|
|
96
|
+
index: (row) => (row.replay === "none" ? null : row.digest),
|
|
97
|
+
}),
|
|
98
|
+
receipts: defineCollection(Receipt, {
|
|
99
|
+
key: (row) => row.id,
|
|
100
|
+
index: (row) => row.digest,
|
|
101
|
+
}),
|
|
102
|
+
asks: defineCollection(AskRow, {
|
|
103
|
+
key: (row) => row.askId,
|
|
104
|
+
index: (row) => row.sessionId,
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
/** Property order and an omitted false flag do not make a different question. */
|
|
108
|
+
function questionDigest(sessionId, workId, questions) {
|
|
109
|
+
return createHash("sha256")
|
|
110
|
+
.update(JSON.stringify([
|
|
111
|
+
sessionId,
|
|
112
|
+
workId,
|
|
113
|
+
questions.map((question) => [
|
|
114
|
+
question.id,
|
|
115
|
+
question.header,
|
|
116
|
+
question.question,
|
|
117
|
+
question.options.map((option) => [option.label, option.description]),
|
|
118
|
+
question.multiple ?? false,
|
|
119
|
+
]),
|
|
120
|
+
]))
|
|
121
|
+
.digest("hex");
|
|
122
|
+
}
|
|
123
|
+
function parkAsk(ctx, row) {
|
|
124
|
+
return Effect.gen(function* () {
|
|
125
|
+
const parked = { ...row, status: "parked", runAttemptId: null };
|
|
126
|
+
yield* ctx.state.asks.put(parked);
|
|
127
|
+
yield* ctx.observe(row.sessionId);
|
|
128
|
+
return parked;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function closedReplay(outcome, followUpWorkId) {
|
|
132
|
+
if (outcome.status === "cancelled")
|
|
133
|
+
return "none";
|
|
134
|
+
if (followUpWorkId !== null)
|
|
135
|
+
return "parked";
|
|
136
|
+
return "outcome";
|
|
137
|
+
}
|
|
138
|
+
/** Every terminal transition commits outcome, delivery and visibility together. */
|
|
139
|
+
function closeAsk(ctx, row, outcome, followUpWorkId = null, toolCallId) {
|
|
140
|
+
return Effect.gen(function* () {
|
|
141
|
+
yield* ctx.state.closed.put({
|
|
142
|
+
...row,
|
|
143
|
+
pending: null,
|
|
144
|
+
runAttemptId: null,
|
|
145
|
+
status: "settled",
|
|
146
|
+
outcome,
|
|
147
|
+
replay: closedReplay(outcome, followUpWorkId),
|
|
148
|
+
delivery: followUpWorkId !== null
|
|
149
|
+
? { via: "follow_up", workId: followUpWorkId, status: "pending" }
|
|
150
|
+
: outcome.status === "answered" ||
|
|
151
|
+
outcome.status === "declined" ||
|
|
152
|
+
outcome.status === "expired"
|
|
153
|
+
? {
|
|
154
|
+
via: "in_turn",
|
|
155
|
+
status: toolCallId === undefined ? "settled" : "pending",
|
|
156
|
+
}
|
|
157
|
+
: { via: "none" },
|
|
158
|
+
});
|
|
159
|
+
if (toolCallId !== undefined)
|
|
160
|
+
yield* recordDelivery(ctx, row, toolCallId);
|
|
161
|
+
yield* ctx.state.asks.remove(row.askId);
|
|
162
|
+
if (row.expiry !== null)
|
|
163
|
+
yield* ctx.timers.cancel(`ask:${row.askId}`);
|
|
164
|
+
yield* ctx.attention.clear(row.sessionId, row.askId);
|
|
165
|
+
yield* ctx.observe(row.sessionId);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function recordDelivery(ctx, row, toolCallId) {
|
|
169
|
+
return ctx.state.deliveries.put({
|
|
170
|
+
id: JSON.stringify([row.askId, toolCallId]),
|
|
171
|
+
askId: row.askId,
|
|
172
|
+
workId: row.workId,
|
|
173
|
+
toolCallId,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function acknowledgeDelivery(ctx, run, receipt) {
|
|
177
|
+
return Effect.gen(function* () {
|
|
178
|
+
const closed = yield* ctx.state.closed.get(receipt.askId);
|
|
179
|
+
if (Option.isSome(closed) &&
|
|
180
|
+
closed.value.delivery.via === "in_turn" &&
|
|
181
|
+
closed.value.delivery.status === "pending" &&
|
|
182
|
+
(yield* ctx.hasSuccessfulToolResult(run, receipt.toolCallId)))
|
|
183
|
+
yield* ctx.state.closed.put({
|
|
184
|
+
...closed.value,
|
|
185
|
+
delivery: { via: "in_turn", status: "settled" },
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function recoverDelivery(ctx, run, askId) {
|
|
190
|
+
return Effect.gen(function* () {
|
|
191
|
+
const closed = yield* ctx.state.closed.get(askId);
|
|
192
|
+
if (Option.isSome(closed) &&
|
|
193
|
+
closed.value.delivery.via === "in_turn" &&
|
|
194
|
+
closed.value.delivery.status === "pending") {
|
|
195
|
+
const row = closed.value;
|
|
196
|
+
if (run.outcome === "cancelled")
|
|
197
|
+
yield* closeAsk(ctx, { ...row, status: "parked" }, { status: "cancelled" });
|
|
198
|
+
else if (row.outcome.status === "answered" ||
|
|
199
|
+
row.outcome.status === "declined" ||
|
|
200
|
+
row.outcome.status === "expired")
|
|
201
|
+
yield* deliverResponse(ctx, { ...row, status: "parked" }, row.outcome);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** A returned value is acknowledged only after Mule has durably saved its result. */
|
|
206
|
+
function reconcileDeliveries(ctx, run) {
|
|
207
|
+
return Effect.gen(function* () {
|
|
208
|
+
// First acknowledge every successful consumer. A failed parallel consumer
|
|
209
|
+
// must not resend an answer another consumer durably received.
|
|
210
|
+
let after;
|
|
211
|
+
for (;;) {
|
|
212
|
+
const page = yield* ctx.state.deliveries.page({
|
|
213
|
+
index: run.workId,
|
|
214
|
+
after,
|
|
215
|
+
limit: 64,
|
|
216
|
+
});
|
|
217
|
+
if (page.length === 0)
|
|
218
|
+
break;
|
|
219
|
+
for (const receipt of page) {
|
|
220
|
+
yield* acknowledgeDelivery(ctx, run, receipt);
|
|
221
|
+
after = receipt.id;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (;;) {
|
|
225
|
+
const page = yield* ctx.state.deliveries.page({
|
|
226
|
+
index: run.workId,
|
|
227
|
+
limit: 64,
|
|
228
|
+
});
|
|
229
|
+
if (page.length === 0)
|
|
230
|
+
break;
|
|
231
|
+
for (const receipt of page) {
|
|
232
|
+
yield* recoverDelivery(ctx, run, receipt.askId);
|
|
233
|
+
yield* ctx.state.deliveries.remove(receipt.id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function settleFollowUp(ctx, run) {
|
|
239
|
+
return Effect.gen(function* () {
|
|
240
|
+
if (run.origin?.extensionId === questionContract.id &&
|
|
241
|
+
run.origin.correlation !== null) {
|
|
242
|
+
const closed = yield* ctx.state.closed.get(run.origin.correlation);
|
|
243
|
+
if (Option.isSome(closed) && closed.value.sessionId === run.sessionId) {
|
|
244
|
+
const row = closed.value;
|
|
245
|
+
if (row.delivery.via === "follow_up" &&
|
|
246
|
+
row.delivery.workId === run.workId &&
|
|
247
|
+
row.delivery.status === "pending")
|
|
248
|
+
yield* ctx.state.closed.put({
|
|
249
|
+
...row,
|
|
250
|
+
delivery: {
|
|
251
|
+
...row.delivery,
|
|
252
|
+
status: run.outcome === "cancelled" ? "withdrawn" : "settled",
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function settle(ctx, run) {
|
|
260
|
+
return Effect.gen(function* () {
|
|
261
|
+
yield* reconcileDeliveries(ctx, run);
|
|
262
|
+
yield* settleFollowUp(ctx, run);
|
|
263
|
+
const open = yield* ctx.state.asks.page({
|
|
264
|
+
index: run.sessionId,
|
|
265
|
+
limit: ctx.config.maximumOpen,
|
|
266
|
+
});
|
|
267
|
+
for (const row of open) {
|
|
268
|
+
if (row.workId !== run.workId)
|
|
269
|
+
continue;
|
|
270
|
+
if (run.outcome === "cancelled")
|
|
271
|
+
yield* closeAsk(ctx, row, { status: "cancelled" });
|
|
272
|
+
else if (row.pending !== null)
|
|
273
|
+
yield* deliverResponse(ctx, row, row.pending);
|
|
274
|
+
else if (row.status === "live")
|
|
275
|
+
yield* parkAsk(ctx, row);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function project(row) {
|
|
280
|
+
return {
|
|
281
|
+
askId: row.askId,
|
|
282
|
+
questions: row.questions,
|
|
283
|
+
status: row.status,
|
|
284
|
+
createdAt: row.createdAt,
|
|
285
|
+
expiresAt: row.expiry?.at ?? null,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const encoder = new TextEncoder();
|
|
289
|
+
/** Keep every question and selection represented within the follow-up byte budget. */
|
|
290
|
+
function clip(text, budget) {
|
|
291
|
+
const bytes = encoder.encode(text);
|
|
292
|
+
if (bytes.length <= budget)
|
|
293
|
+
return text;
|
|
294
|
+
const suffix = "… [truncated]";
|
|
295
|
+
const room = budget - encoder.encode(suffix).length;
|
|
296
|
+
if (room <= 0)
|
|
297
|
+
return "";
|
|
298
|
+
return (new TextDecoder().decode(bytes.subarray(0, room), { stream: true }) + suffix);
|
|
299
|
+
}
|
|
300
|
+
function responsePrompt(row, response) {
|
|
301
|
+
const prefix = response.status === "answered"
|
|
302
|
+
? `Answer to ask ${row.askId}:\n`
|
|
303
|
+
: response.status === "declined"
|
|
304
|
+
? `Declined ask ${row.askId}:\n${response.note === undefined ? "" : `Note: ${clip(response.note, 2_048)}\n`}`
|
|
305
|
+
: `Ask ${row.askId} expired; ${response.action === "stop" ? "do not proceed with this decision" : "recommended default"}:\n`;
|
|
306
|
+
const perQuestion = Math.floor((8_192 - encoder.encode(prefix).length) / row.questions.length) -
|
|
307
|
+
1;
|
|
308
|
+
const byId = response.status === "answered" ||
|
|
309
|
+
(response.status === "expired" &&
|
|
310
|
+
response.action === "proceed_with_recommended")
|
|
311
|
+
? new Map(Object.entries(response.answers))
|
|
312
|
+
: undefined;
|
|
313
|
+
return (prefix +
|
|
314
|
+
row.questions
|
|
315
|
+
.map((question) => {
|
|
316
|
+
const questionText = `${question.header} (${question.id}): ${clip(question.question, Math.floor(perQuestion / 3))}`;
|
|
317
|
+
if (byId === undefined)
|
|
318
|
+
return questionText;
|
|
319
|
+
const answer = byId.get(question.id);
|
|
320
|
+
if (answer === undefined)
|
|
321
|
+
throw new Error("Validated answer is missing");
|
|
322
|
+
const selection = answer.selected.length === 0
|
|
323
|
+
? "(free text)"
|
|
324
|
+
: answer.selected.join(", ");
|
|
325
|
+
const base = `${questionText}\nSelected: ${selection}`;
|
|
326
|
+
if (answer.text === undefined || answer.text.length === 0)
|
|
327
|
+
return base;
|
|
328
|
+
const heading = base + "\nText: ";
|
|
329
|
+
return (heading +
|
|
330
|
+
clip(answer.text, perQuestion - encoder.encode(heading).length));
|
|
331
|
+
})
|
|
332
|
+
.join("\n"));
|
|
333
|
+
}
|
|
334
|
+
/** Commands and settlement use the same atomic follow-up admission and closure. */
|
|
335
|
+
function deliverResponse(ctx, row, response) {
|
|
336
|
+
return Effect.gen(function* () {
|
|
337
|
+
const delivered = yield* ctx
|
|
338
|
+
.send({
|
|
339
|
+
sessionId: row.sessionId,
|
|
340
|
+
requireExistingSession: true,
|
|
341
|
+
when: "now",
|
|
342
|
+
correlation: row.askId,
|
|
343
|
+
prompt: {
|
|
344
|
+
text: responsePrompt(row, response),
|
|
345
|
+
requestId: `ask:${row.askId}`,
|
|
346
|
+
delivery: "followUp",
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
.pipe(Effect.map((sent) => sent._tag === "Accepted"
|
|
350
|
+
? { status: "sent", workId: sent.commandId }
|
|
351
|
+
: { status: "failed", reason: "not_ready" }), Effect.catchTag("AdmissionError", (error) => Effect.succeed({ status: "failed", reason: error.reason })));
|
|
352
|
+
const outcome = delivered.status === "sent"
|
|
353
|
+
? response
|
|
354
|
+
: { status: "undeliverable", reason: delivered.reason };
|
|
355
|
+
yield* closeAsk(ctx, row, outcome, delivered.status === "sent" ? delivered.workId : null);
|
|
356
|
+
return delivered.status === "sent"
|
|
357
|
+
? { result: "accepted", via: "follow_up" }
|
|
358
|
+
: { result: "closed", status: "undeliverable" };
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/** Live responses await the tool; parked responses cross the send door now. */
|
|
362
|
+
function offerResponse(ctx, row, response) {
|
|
363
|
+
return Effect.gen(function* () {
|
|
364
|
+
if (row.status === "parked")
|
|
365
|
+
return yield* deliverResponse(ctx, row, response);
|
|
366
|
+
yield* ctx.state.asks.put({ ...row, pending: response });
|
|
367
|
+
yield* ctx.attention.clear(row.sessionId, row.askId);
|
|
368
|
+
yield* ctx.observe(row.sessionId);
|
|
369
|
+
return { result: "accepted", via: "in_turn" };
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/** One expiry transition, shared by durable timers and a live waiter. */
|
|
373
|
+
function expireAsk(ctx, row, now) {
|
|
374
|
+
return Effect.gen(function* () {
|
|
375
|
+
if (row.expiry === null || now < row.expiry.at || row.pending !== null)
|
|
376
|
+
return undefined;
|
|
377
|
+
const response = row.expiry.action === "stop"
|
|
378
|
+
? { status: "expired", action: "stop" }
|
|
379
|
+
: {
|
|
380
|
+
status: "expired",
|
|
381
|
+
action: "proceed_with_recommended",
|
|
382
|
+
answers: Object.fromEntries(row.questions.map((question) => [
|
|
383
|
+
question.id,
|
|
384
|
+
{
|
|
385
|
+
selected: question.options
|
|
386
|
+
.slice(0, 1)
|
|
387
|
+
.map((option) => option.label),
|
|
388
|
+
},
|
|
389
|
+
])),
|
|
390
|
+
};
|
|
391
|
+
yield* offerResponse(ctx, row, response);
|
|
392
|
+
return response;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function validAnswers(questions, values) {
|
|
396
|
+
const answers = new Map(Object.entries(values));
|
|
397
|
+
const valid = answers.size === questions.length &&
|
|
398
|
+
questions.every((question) => {
|
|
399
|
+
const answer = answers.get(question.id);
|
|
400
|
+
return (answer !== undefined &&
|
|
401
|
+
(question.multiple === true || answer.selected.length <= 1) &&
|
|
402
|
+
new Set(answer.selected).size === answer.selected.length &&
|
|
403
|
+
answer.selected.every((label) => question.options.some((option) => option.label === label)) &&
|
|
404
|
+
(answer.selected.length > 0 || (answer.text?.trim().length ?? 0) > 0));
|
|
405
|
+
});
|
|
406
|
+
return valid;
|
|
407
|
+
}
|
|
408
|
+
function responseIdentity(input) {
|
|
409
|
+
const response = input.kind === "answer"
|
|
410
|
+
? { status: "answered", answers: input.answers }
|
|
411
|
+
: input.note === undefined
|
|
412
|
+
? { status: "declined" }
|
|
413
|
+
: { status: "declined", note: input.note };
|
|
414
|
+
const payload = JSON.stringify([
|
|
415
|
+
input.kind,
|
|
416
|
+
input.askId,
|
|
417
|
+
input.kind === "answer"
|
|
418
|
+
? Object.entries(input.answers)
|
|
419
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
420
|
+
.map(([id, answer]) => [
|
|
421
|
+
id,
|
|
422
|
+
[...answer.selected].sort(),
|
|
423
|
+
answer.text ?? null,
|
|
424
|
+
])
|
|
425
|
+
: (input.note ?? null),
|
|
426
|
+
]);
|
|
427
|
+
return { response, payload };
|
|
428
|
+
}
|
|
429
|
+
function respond(ctx, sessionId, input) {
|
|
430
|
+
return Effect.gen(function* () {
|
|
431
|
+
const { response, payload } = responseIdentity(input);
|
|
432
|
+
const receiptDigest = createHash("sha256")
|
|
433
|
+
.update(JSON.stringify([sessionId, input.requestId]))
|
|
434
|
+
.digest("hex");
|
|
435
|
+
const prior = (yield* ctx.state.receipts.page({
|
|
436
|
+
index: receiptDigest,
|
|
437
|
+
limit: 1,
|
|
438
|
+
}))[0];
|
|
439
|
+
if (prior !== undefined) {
|
|
440
|
+
if (prior.payload !== payload)
|
|
441
|
+
return yield* Effect.fail(new ExtensionCommandError({
|
|
442
|
+
reason: "conflict",
|
|
443
|
+
message: "This request id was used for a different response",
|
|
444
|
+
}));
|
|
445
|
+
return prior.result;
|
|
446
|
+
}
|
|
447
|
+
const record = (result) => ctx.state.receipts
|
|
448
|
+
.put({
|
|
449
|
+
id: nanoid(),
|
|
450
|
+
digest: receiptDigest,
|
|
451
|
+
sessionId,
|
|
452
|
+
requestId: input.requestId,
|
|
453
|
+
payload,
|
|
454
|
+
result,
|
|
455
|
+
})
|
|
456
|
+
.pipe(Effect.as(result));
|
|
457
|
+
const found = yield* ctx.state.asks.get(input.askId);
|
|
458
|
+
if (Option.isNone(found) || found.value.sessionId !== sessionId) {
|
|
459
|
+
const closed = yield* ctx.state.closed.get(input.askId);
|
|
460
|
+
return yield* record(Option.isSome(closed) && closed.value.sessionId === sessionId
|
|
461
|
+
? { result: "closed", status: closed.value.outcome.status }
|
|
462
|
+
: { result: "not_found" });
|
|
463
|
+
}
|
|
464
|
+
const row = found.value;
|
|
465
|
+
if (row.pending !== null)
|
|
466
|
+
return yield* record({ result: "closed", status: row.pending.status });
|
|
467
|
+
if (input.kind === "answer") {
|
|
468
|
+
const valid = validAnswers(row.questions, input.answers);
|
|
469
|
+
if (!valid)
|
|
470
|
+
return yield* Effect.fail(new ExtensionCommandError({
|
|
471
|
+
reason: "invalid",
|
|
472
|
+
message: "Answer every question using its offered labels or nonempty text",
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
return yield* record(yield* offerResponse(ctx, row, response));
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
// Mycelium's maintained operation interface uses Zod. The owner additionally
|
|
479
|
+
// decodes Questions for shared domain refinements and durable state.
|
|
480
|
+
const baseInput = z.strictObject({
|
|
481
|
+
questions: z
|
|
482
|
+
.array(z.strictObject({
|
|
483
|
+
id: z.string().min(1).max(32),
|
|
484
|
+
header: z.string().min(1).max(12),
|
|
485
|
+
question: z.string().min(1).max(1_000),
|
|
486
|
+
options: z
|
|
487
|
+
.array(z.strictObject({
|
|
488
|
+
label: z.string().min(1).max(60),
|
|
489
|
+
description: z.string().min(1).max(300),
|
|
490
|
+
}))
|
|
491
|
+
.min(2)
|
|
492
|
+
.max(4),
|
|
493
|
+
multiple: z.boolean().default(false),
|
|
494
|
+
}))
|
|
495
|
+
.min(1)
|
|
496
|
+
.max(4),
|
|
497
|
+
mode: z.enum(["wait", "continue"]).default("wait"),
|
|
498
|
+
});
|
|
499
|
+
const input = z.union([
|
|
500
|
+
baseInput.extend({
|
|
501
|
+
onExpire: z.strictObject({
|
|
502
|
+
afterMinutes: z
|
|
503
|
+
.number()
|
|
504
|
+
.int()
|
|
505
|
+
.min(5)
|
|
506
|
+
.max(7 * 24 * 60),
|
|
507
|
+
action: z.enum(["stop", "proceed_with_recommended"]),
|
|
508
|
+
}),
|
|
509
|
+
}),
|
|
510
|
+
baseInput,
|
|
511
|
+
]);
|
|
512
|
+
const deadlineReserveMs = 60_000;
|
|
513
|
+
const pollIntervalMs = 1_000;
|
|
514
|
+
/** Wake on abort as well as the polling edge; durable state owns the response. */
|
|
515
|
+
function pause(ms, signal) {
|
|
516
|
+
return new Promise((resolve) => {
|
|
517
|
+
const finish = () => {
|
|
518
|
+
clearTimeout(timer);
|
|
519
|
+
signal.removeEventListener("abort", finish);
|
|
520
|
+
resolve();
|
|
521
|
+
};
|
|
522
|
+
const timer = setTimeout(finish, ms);
|
|
523
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
524
|
+
if (signal.aborted)
|
|
525
|
+
finish();
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
async function waitForResponse(ctx, askId, until, invocation) {
|
|
529
|
+
for (;;) {
|
|
530
|
+
const next = await Effect.runPromise(ctx.transact(Effect.gen(function* () {
|
|
531
|
+
const found = yield* ctx.state.asks.get(askId);
|
|
532
|
+
if (invocation.signal.aborted) {
|
|
533
|
+
if (Option.isSome(found))
|
|
534
|
+
yield* parkAsk(ctx, found.value);
|
|
535
|
+
return { status: "aborted" };
|
|
536
|
+
}
|
|
537
|
+
if (Option.isNone(found)) {
|
|
538
|
+
const closed = yield* ctx.state.closed.get(askId);
|
|
539
|
+
if (Option.isSome(closed) &&
|
|
540
|
+
closed.value.outcome.status === "withdrawn")
|
|
541
|
+
return closed.value.outcome;
|
|
542
|
+
throw new Error("Waiting question disappeared");
|
|
543
|
+
}
|
|
544
|
+
const row = found.value;
|
|
545
|
+
if (row.pending !== null) {
|
|
546
|
+
yield* closeAsk(ctx, row, row.pending, null, invocation.toolCallId);
|
|
547
|
+
return row.pending;
|
|
548
|
+
}
|
|
549
|
+
const now = yield* Clock.currentTimeMillis;
|
|
550
|
+
const expired = yield* expireAsk(ctx, row, now);
|
|
551
|
+
if (expired !== undefined) {
|
|
552
|
+
yield* closeAsk(ctx, row, expired, null, invocation.toolCallId);
|
|
553
|
+
return expired;
|
|
554
|
+
}
|
|
555
|
+
const pendingSteer = yield* ctx.hasPendingSteer({
|
|
556
|
+
sessionId: row.sessionId,
|
|
557
|
+
workId: row.workId,
|
|
558
|
+
});
|
|
559
|
+
if (now >= until || pendingSteer) {
|
|
560
|
+
yield* parkAsk(ctx, row);
|
|
561
|
+
return { status: "parked", askId };
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
status: "waiting",
|
|
565
|
+
delay: Math.min(pollIntervalMs, until - now),
|
|
566
|
+
};
|
|
567
|
+
})));
|
|
568
|
+
if (next.status === "aborted") {
|
|
569
|
+
invocation.signal.throwIfAborted();
|
|
570
|
+
throw new Error("Aborted invocation has no abort reason");
|
|
571
|
+
}
|
|
572
|
+
if (next.status !== "waiting")
|
|
573
|
+
return next;
|
|
574
|
+
await pause(next.delay, invocation.signal);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const toolAnswers = z.record(z.string(), z.union([
|
|
578
|
+
z.strictObject({ selected: z.array(z.string()), text: z.string() }),
|
|
579
|
+
z.strictObject({ selected: z.array(z.string()) }),
|
|
580
|
+
]));
|
|
581
|
+
const ToolResult = z.union([
|
|
582
|
+
z.strictObject({ status: z.literal("withdrawn") }),
|
|
583
|
+
z.strictObject({ status: z.literal("undeliverable"), reason: z.string() }),
|
|
584
|
+
z.strictObject({
|
|
585
|
+
status: z.literal("parked"),
|
|
586
|
+
askId: z.string(),
|
|
587
|
+
}),
|
|
588
|
+
z.strictObject({
|
|
589
|
+
status: z.literal("answered"),
|
|
590
|
+
answers: toolAnswers,
|
|
591
|
+
}),
|
|
592
|
+
z.strictObject({ status: z.literal("declined"), note: z.string() }),
|
|
593
|
+
z.strictObject({ status: z.literal("declined") }),
|
|
594
|
+
z.strictObject({ status: z.literal("expired"), action: z.literal("stop") }),
|
|
595
|
+
z.strictObject({
|
|
596
|
+
status: z.literal("expired"),
|
|
597
|
+
action: z.literal("proceed_with_recommended"),
|
|
598
|
+
answers: toolAnswers,
|
|
599
|
+
}),
|
|
600
|
+
z.strictObject({
|
|
601
|
+
status: z.literal("refused"),
|
|
602
|
+
reason: z.enum(["session_limit", "live_limit"]),
|
|
603
|
+
askIds: z.array(z.string()),
|
|
604
|
+
}),
|
|
605
|
+
]);
|
|
606
|
+
const WithdrawResult = z.union([
|
|
607
|
+
z.strictObject({
|
|
608
|
+
result: z.literal("closed"),
|
|
609
|
+
status: z.enum(ClosedStatus.literals),
|
|
610
|
+
}),
|
|
611
|
+
z.strictObject({ result: z.literal("not_found") }),
|
|
612
|
+
]);
|
|
613
|
+
function withdraw(ctx, sessionId, askId) {
|
|
614
|
+
return Effect.gen(function* () {
|
|
615
|
+
const found = yield* ctx.state.asks.get(askId);
|
|
616
|
+
if (Option.isSome(found) && found.value.sessionId === sessionId) {
|
|
617
|
+
const row = found.value;
|
|
618
|
+
if (row.pending !== null)
|
|
619
|
+
return { result: "closed", status: row.pending.status };
|
|
620
|
+
yield* closeAsk(ctx, row, { status: "withdrawn" });
|
|
621
|
+
return { result: "closed", status: "withdrawn" };
|
|
622
|
+
}
|
|
623
|
+
const closed = yield* ctx.state.closed.get(askId);
|
|
624
|
+
return Option.isSome(closed) && closed.value.sessionId === sessionId
|
|
625
|
+
? { result: "closed", status: closed.value.outcome.status }
|
|
626
|
+
: { result: "not_found" };
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
function answerNotice(result) {
|
|
630
|
+
if (result.result === "accepted")
|
|
631
|
+
return done({ type: "success", message: "Answer sent." });
|
|
632
|
+
return done({
|
|
633
|
+
type: "info",
|
|
634
|
+
message: result.result === "closed"
|
|
635
|
+
? `Question closed: ${result.status}.`
|
|
636
|
+
: "That question is no longer available.",
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
function chooseQuestion(ctx, sessionId, askId) {
|
|
640
|
+
return Effect.gen(function* () {
|
|
641
|
+
if (askId === undefined) {
|
|
642
|
+
const rows = (yield* ctx.state.asks.page({
|
|
643
|
+
index: sessionId,
|
|
644
|
+
limit: ctx.config.maximumOpen,
|
|
645
|
+
})).filter((row) => row.pending === null);
|
|
646
|
+
if (rows.length === 0)
|
|
647
|
+
return done({ type: "info", message: "No questions are waiting." });
|
|
648
|
+
if (rows.length > 1)
|
|
649
|
+
return ask({
|
|
650
|
+
type: "choice",
|
|
651
|
+
message: "Which question would you like to answer?",
|
|
652
|
+
options: rows.map((row) => ({
|
|
653
|
+
value: row.askId,
|
|
654
|
+
label: row.questions
|
|
655
|
+
.map((question) => question.header)
|
|
656
|
+
.join(" · "),
|
|
657
|
+
})),
|
|
658
|
+
}, {}, "askId");
|
|
659
|
+
const first = rows[0];
|
|
660
|
+
if (first === undefined)
|
|
661
|
+
throw new Error("Expected the waiting question");
|
|
662
|
+
return { type: "selected", askId: first.askId };
|
|
663
|
+
}
|
|
664
|
+
return { type: "selected", askId };
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
function multipleSelection(question, selection) {
|
|
668
|
+
const parsed = Papa.parse(selection, {
|
|
669
|
+
delimiter: ",",
|
|
670
|
+
skipEmptyLines: true,
|
|
671
|
+
});
|
|
672
|
+
const selected = parsed.data[0] ?? [];
|
|
673
|
+
const offered = new Set(question.options.map((option) => option.label));
|
|
674
|
+
if (question.multiple !== true ||
|
|
675
|
+
parsed.errors.length > 0 ||
|
|
676
|
+
parsed.data.length > 1 ||
|
|
677
|
+
new Set(selected).size !== selected.length ||
|
|
678
|
+
selected.some((label) => !offered.has(label)))
|
|
679
|
+
return Effect.fail(new ExtensionCommandError({
|
|
680
|
+
reason: "invalid",
|
|
681
|
+
message: "Enter offered labels once each as a comma list; quote labels containing commas",
|
|
682
|
+
}));
|
|
683
|
+
return Effect.succeed(selected);
|
|
684
|
+
}
|
|
685
|
+
function singleSelection(question, selection) {
|
|
686
|
+
if (selection === "free_text")
|
|
687
|
+
return Effect.succeed([]);
|
|
688
|
+
const option = question.options.find((option) => `option:${option.label}` === selection);
|
|
689
|
+
return option === undefined
|
|
690
|
+
? Effect.fail(new ExtensionCommandError({
|
|
691
|
+
reason: "invalid",
|
|
692
|
+
message: "Choose an offered option or free text",
|
|
693
|
+
}))
|
|
694
|
+
: Effect.succeed([option.label]);
|
|
695
|
+
}
|
|
696
|
+
function promptSelection(question, next, input) {
|
|
697
|
+
const selected = input.stage === "multiple"
|
|
698
|
+
? multipleSelection(question, input.selection)
|
|
699
|
+
: singleSelection(question, input.selection);
|
|
700
|
+
return selected.pipe(Effect.map((labels) => ask({
|
|
701
|
+
type: "text",
|
|
702
|
+
message: labels.length === 0
|
|
703
|
+
? `Your answer for ${question.header}:`
|
|
704
|
+
: `Additional context for ${question.header} (leave blank to skip):`,
|
|
705
|
+
}, { stage: "text", ...next, selected: labels }, "text")));
|
|
706
|
+
}
|
|
707
|
+
function promptQuestion(nextQuestion, next) {
|
|
708
|
+
if (nextQuestion.multiple === true)
|
|
709
|
+
return ask({
|
|
710
|
+
type: "text",
|
|
711
|
+
message: `${nextQuestion.question}\n${nextQuestion.options.map((option) => `${Papa.unparse([[option.label]])}: ${option.description}`).join("\n")}\nEnter selected labels as a comma list (quote labels containing commas), or leave blank for free text.`,
|
|
712
|
+
}, { stage: "multiple", ...next }, "selection");
|
|
713
|
+
return ask({
|
|
714
|
+
type: "choice",
|
|
715
|
+
message: nextQuestion.question,
|
|
716
|
+
options: [
|
|
717
|
+
...nextQuestion.options.map((option) => ({
|
|
718
|
+
value: `option:${option.label}`,
|
|
719
|
+
label: option.label,
|
|
720
|
+
description: option.description,
|
|
721
|
+
})),
|
|
722
|
+
{ value: "free_text", label: "Write an answer" },
|
|
723
|
+
],
|
|
724
|
+
}, { stage: "selection", ...next }, "selection");
|
|
725
|
+
}
|
|
726
|
+
function answerFlow(ctx, sessionId, input) {
|
|
727
|
+
return Effect.gen(function* () {
|
|
728
|
+
const selection = yield* chooseQuestion(ctx, sessionId, "askId" in input ? input.askId : undefined);
|
|
729
|
+
if (selection.type !== "selected")
|
|
730
|
+
return selection;
|
|
731
|
+
const { askId } = selection;
|
|
732
|
+
const open = yield* ctx.state.asks.get(askId);
|
|
733
|
+
const row = Option.getOrNull(open) ??
|
|
734
|
+
Option.getOrNull(yield* ctx.state.closed.get(askId));
|
|
735
|
+
if (row?.sessionId !== sessionId)
|
|
736
|
+
return done({
|
|
737
|
+
type: "info",
|
|
738
|
+
message: "That question is no longer available.",
|
|
739
|
+
});
|
|
740
|
+
return yield* answerQuestion(ctx, sessionId, row, input);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
/** Progress the selected Ask's draft; request replay still belongs to respond. */
|
|
744
|
+
function answerQuestion(ctx, sessionId, row, input) {
|
|
745
|
+
return Effect.gen(function* () {
|
|
746
|
+
const draft = "stage" in input
|
|
747
|
+
? input
|
|
748
|
+
: {
|
|
749
|
+
stage: "start",
|
|
750
|
+
index: 0,
|
|
751
|
+
requestId: yield* freshRequestId,
|
|
752
|
+
answers: {},
|
|
753
|
+
};
|
|
754
|
+
let { index, answers } = draft;
|
|
755
|
+
const { requestId } = draft;
|
|
756
|
+
const question = row.questions[index];
|
|
757
|
+
if (question === undefined)
|
|
758
|
+
return yield* Effect.fail(new ExtensionCommandError({
|
|
759
|
+
reason: "invalid",
|
|
760
|
+
message: "Unknown question step",
|
|
761
|
+
}));
|
|
762
|
+
if (draft.stage === "text") {
|
|
763
|
+
const answer = draft.text.length === 0
|
|
764
|
+
? { selected: draft.selected }
|
|
765
|
+
: { selected: draft.selected, text: draft.text };
|
|
766
|
+
answers = { ...answers, [question.id]: answer };
|
|
767
|
+
index += 1;
|
|
768
|
+
if (index === row.questions.length) {
|
|
769
|
+
const result = yield* respond(ctx, sessionId, {
|
|
770
|
+
kind: "answer",
|
|
771
|
+
askId: row.askId,
|
|
772
|
+
requestId,
|
|
773
|
+
answers,
|
|
774
|
+
});
|
|
775
|
+
return answerNotice(result);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (row.status === "settled" || row.pending !== null)
|
|
779
|
+
return done({
|
|
780
|
+
type: "info",
|
|
781
|
+
message: "That question is already closed.",
|
|
782
|
+
});
|
|
783
|
+
const next = { askId: row.askId, requestId, index, answers };
|
|
784
|
+
if (draft.stage === "selection" || draft.stage === "multiple")
|
|
785
|
+
return yield* promptSelection(question, next, draft);
|
|
786
|
+
const nextQuestion = row.questions[index];
|
|
787
|
+
if (nextQuestion === undefined)
|
|
788
|
+
throw new Error("Expected the next question");
|
|
789
|
+
return promptQuestion(nextQuestion, next);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
function replayQuestion(ctx, open, workId, digest, toolCallId) {
|
|
793
|
+
return Effect.gen(function* () {
|
|
794
|
+
const prior = open.find((row) => row.workId === workId && row.digest === digest);
|
|
795
|
+
if (prior !== undefined) {
|
|
796
|
+
if (prior.status === "parked" && prior.pending !== null) {
|
|
797
|
+
yield* closeAsk(ctx, prior, prior.pending, null, toolCallId);
|
|
798
|
+
return prior.pending;
|
|
799
|
+
}
|
|
800
|
+
return { status: "parked", askId: prior.askId };
|
|
801
|
+
}
|
|
802
|
+
const consumed = (yield* ctx.state.closed.page({
|
|
803
|
+
index: digest,
|
|
804
|
+
limit: 1,
|
|
805
|
+
}))[0];
|
|
806
|
+
if (consumed?.replay === "parked")
|
|
807
|
+
return {
|
|
808
|
+
status: "parked",
|
|
809
|
+
askId: consumed.askId,
|
|
810
|
+
};
|
|
811
|
+
if (consumed?.outcome.status === "answered" ||
|
|
812
|
+
consumed?.outcome.status === "declined" ||
|
|
813
|
+
consumed?.outcome.status === "expired" ||
|
|
814
|
+
consumed?.outcome.status === "withdrawn" ||
|
|
815
|
+
consumed?.outcome.status === "undeliverable") {
|
|
816
|
+
if (consumed.delivery.via === "in_turn" &&
|
|
817
|
+
consumed.delivery.status === "pending" &&
|
|
818
|
+
toolCallId !== undefined)
|
|
819
|
+
yield* recordDelivery(ctx, consumed, toolCallId);
|
|
820
|
+
return consumed.outcome;
|
|
821
|
+
}
|
|
822
|
+
return undefined;
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
function answerCatalog(available) {
|
|
826
|
+
return catalog([
|
|
827
|
+
{
|
|
828
|
+
id: "answerFlow",
|
|
829
|
+
title: "Answer a question",
|
|
830
|
+
category: "Conversation",
|
|
831
|
+
description: "Answer a waiting question",
|
|
832
|
+
availability: available
|
|
833
|
+
? { status: "available" }
|
|
834
|
+
: {
|
|
835
|
+
status: "unavailable",
|
|
836
|
+
reason: "There is no waiting question to answer.",
|
|
837
|
+
},
|
|
838
|
+
projections: {
|
|
839
|
+
slash: { aliases: ["answer"], order: 5 },
|
|
840
|
+
palette: { order: 5 },
|
|
841
|
+
help: { order: 5 },
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
]);
|
|
845
|
+
}
|
|
846
|
+
/** One durable question owner over the ordinary extension doors. */
|
|
847
|
+
export const questionExtension = defineExtension({
|
|
848
|
+
id: questionContract.id,
|
|
849
|
+
version: "1.0.0",
|
|
850
|
+
})
|
|
851
|
+
.config(config)
|
|
852
|
+
.state({ version: 1, migrations: {}, collections })
|
|
853
|
+
.define({
|
|
854
|
+
catalog: answerCatalog(false),
|
|
855
|
+
attention: true,
|
|
856
|
+
timers: true,
|
|
857
|
+
hooks: {
|
|
858
|
+
onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run), { discard: true }),
|
|
859
|
+
onTimer: (ctx, timer) => Effect.gen(function* () {
|
|
860
|
+
if (!timer.key.startsWith("ask:"))
|
|
861
|
+
return;
|
|
862
|
+
const row = yield* ctx.state.asks.get(timer.key.slice(4));
|
|
863
|
+
if (Option.isSome(row))
|
|
864
|
+
yield* expireAsk(ctx, row.value, timer.firedAt);
|
|
865
|
+
}),
|
|
866
|
+
},
|
|
867
|
+
prompt: (ctx) => [{ name: "question", text: ctx.config.guide }],
|
|
868
|
+
projection: {
|
|
869
|
+
schema: questionContract.projection.schema,
|
|
870
|
+
catalog: (value) => answerCatalog(value.asks.length > 0),
|
|
871
|
+
read: (ctx, sessionId) => Option.some({
|
|
872
|
+
asks: ctx.state.asks
|
|
873
|
+
.peekPage({ index: sessionId, limit: ctx.config.maximumOpen })
|
|
874
|
+
.filter((row) => row.pending === null)
|
|
875
|
+
.map(project),
|
|
876
|
+
}),
|
|
877
|
+
},
|
|
878
|
+
commands: {
|
|
879
|
+
specs: questionContract.commands,
|
|
880
|
+
handlers: (ctx) => ({
|
|
881
|
+
answerFlow: ({ sessionId, input }) => answerFlow(ctx, sessionId, input),
|
|
882
|
+
answer: ({ sessionId, input }) => respond(ctx, sessionId, { kind: "answer", ...input }),
|
|
883
|
+
decline: ({ sessionId, input }) => respond(ctx, sessionId, { kind: "decline", ...input }),
|
|
884
|
+
list: ({ sessionId }) => ctx.state.asks
|
|
885
|
+
.page({ index: sessionId, limit: ctx.config.maximumOpen })
|
|
886
|
+
.pipe(Effect.map((rows) => ({
|
|
887
|
+
asks: rows.filter((row) => row.pending === null).map(project),
|
|
888
|
+
}))),
|
|
889
|
+
}),
|
|
890
|
+
},
|
|
891
|
+
tools: (ctx, { sessionId, workId }) => {
|
|
892
|
+
const runAttemptId = nanoid();
|
|
893
|
+
return Mycelium.module({
|
|
894
|
+
id: "botanical.session-question",
|
|
895
|
+
name: "question",
|
|
896
|
+
description: ctx.config.guide,
|
|
897
|
+
operations: {
|
|
898
|
+
withdraw: Mycelium.operation({
|
|
899
|
+
replay: "unsafe",
|
|
900
|
+
description: "Withdraw a question that no longer needs a human answer. Repeating withdrawal preserves its closed outcome.",
|
|
901
|
+
input: z.strictObject({ askId: z.string().min(1).max(256) }),
|
|
902
|
+
output: WithdrawResult,
|
|
903
|
+
execute: async ({ askId }) => WithdrawResult.parse(await Effect.runPromise(ctx.transact(withdraw(ctx, sessionId, askId)))),
|
|
904
|
+
}),
|
|
905
|
+
ask: Mycelium.operation({
|
|
906
|
+
replay: "unsafe",
|
|
907
|
+
description: ctx.config.guide,
|
|
908
|
+
input,
|
|
909
|
+
output: ToolResult,
|
|
910
|
+
execute: async (value, invocation) => {
|
|
911
|
+
const created = await Effect.runPromise(ctx.transact(Effect.gen(function* () {
|
|
912
|
+
const questions = yield* Schema.decodeUnknownEffect(Questions)(value.questions).pipe(Effect.mapError(() => new ExtensionCommandError({
|
|
913
|
+
reason: "invalid",
|
|
914
|
+
message: "Invalid question identifiers or option labels",
|
|
915
|
+
})));
|
|
916
|
+
const digest = questionDigest(sessionId, workId, questions);
|
|
917
|
+
const rows = yield* ctx.state.asks.page({
|
|
918
|
+
index: sessionId,
|
|
919
|
+
limit: ctx.config.maximumOpen,
|
|
920
|
+
});
|
|
921
|
+
const open = yield* Effect.forEach(rows, (row) => row.workId === workId &&
|
|
922
|
+
row.status === "live" &&
|
|
923
|
+
row.runAttemptId !== runAttemptId
|
|
924
|
+
? parkAsk(ctx, row)
|
|
925
|
+
: Effect.succeed(row));
|
|
926
|
+
const replay = yield* replayQuestion(ctx, open, workId, digest, invocation.toolCallId);
|
|
927
|
+
if (replay !== undefined)
|
|
928
|
+
return replay;
|
|
929
|
+
if (open.length === ctx.config.maximumOpen)
|
|
930
|
+
return {
|
|
931
|
+
status: "refused",
|
|
932
|
+
reason: "session_limit",
|
|
933
|
+
askIds: open.map((row) => row.askId),
|
|
934
|
+
};
|
|
935
|
+
const now = yield* Clock.currentTimeMillis;
|
|
936
|
+
const window = invocation.deadlineAt === null
|
|
937
|
+
? 0
|
|
938
|
+
: Math.max(0, Math.min(ctx.config.liveWindowMs, invocation.deadlineAt - now - deadlineReserveMs));
|
|
939
|
+
const live = value.mode === "wait" && window > 0;
|
|
940
|
+
const existingLive = open.filter((row) => row.workId === workId && row.status === "live");
|
|
941
|
+
if (live && existingLive.length > 0)
|
|
942
|
+
return {
|
|
943
|
+
status: "refused",
|
|
944
|
+
reason: "live_limit",
|
|
945
|
+
askIds: existingLive.map((row) => row.askId),
|
|
946
|
+
};
|
|
947
|
+
const row = {
|
|
948
|
+
askId: nanoid(),
|
|
949
|
+
sessionId,
|
|
950
|
+
workId,
|
|
951
|
+
digest,
|
|
952
|
+
questions,
|
|
953
|
+
status: live ? "live" : "parked",
|
|
954
|
+
runAttemptId: live ? runAttemptId : null,
|
|
955
|
+
pending: null,
|
|
956
|
+
createdAt: now,
|
|
957
|
+
expiry: "onExpire" in value
|
|
958
|
+
? {
|
|
959
|
+
at: now + value.onExpire.afterMinutes * 60_000,
|
|
960
|
+
action: value.onExpire.action,
|
|
961
|
+
}
|
|
962
|
+
: null,
|
|
963
|
+
};
|
|
964
|
+
yield* ctx.state.asks.put(row);
|
|
965
|
+
if (row.expiry !== null)
|
|
966
|
+
yield* ctx.timers.set(`ask:${row.askId}`, row.expiry.at);
|
|
967
|
+
const attention = {
|
|
968
|
+
id: row.askId,
|
|
969
|
+
kind: "question",
|
|
970
|
+
summary: questions
|
|
971
|
+
.map((question) => question.header)
|
|
972
|
+
.join(", "),
|
|
973
|
+
since: now,
|
|
974
|
+
};
|
|
975
|
+
yield* ctx.attention.set(sessionId, row.expiry === null
|
|
976
|
+
? attention
|
|
977
|
+
: { ...attention, expiresAt: row.expiry.at });
|
|
978
|
+
yield* ctx.observe(sessionId);
|
|
979
|
+
return live
|
|
980
|
+
? {
|
|
981
|
+
status: "waiting",
|
|
982
|
+
askId: row.askId,
|
|
983
|
+
until: now + window,
|
|
984
|
+
}
|
|
985
|
+
: { status: "parked", askId: row.askId };
|
|
986
|
+
})));
|
|
987
|
+
const result = created.status === "waiting"
|
|
988
|
+
? await waitForResponse(ctx, created.askId, created.until, invocation)
|
|
989
|
+
: created;
|
|
990
|
+
// Zod owns the JSON output representation, including mutable arrays.
|
|
991
|
+
return ToolResult.parse(result);
|
|
992
|
+
},
|
|
993
|
+
}),
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
},
|
|
997
|
+
});
|