@fungi.computer/shiitake-extensions 0.3.0 → 0.5.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/README.md +20 -16
- package/dist/goal/contract.d.ts +6 -0
- package/dist/goal/contract.js +6 -0
- package/dist/goal/host.js +183 -31
- package/dist/loop/contract.d.ts +136 -2
- package/dist/loop/contract.js +45 -2
- package/dist/loop/host.js +23 -0
- package/dist/question/answer-flow.d.ts +27 -0
- package/dist/question/answer-flow.js +115 -0
- package/dist/question/client.d.ts +1 -1
- package/dist/question/client.js +22 -3
- package/dist/question/contract.d.ts +202 -60
- package/dist/question/contract.js +78 -22
- package/dist/question/host.d.ts +4 -0
- package/dist/question/host.js +348 -339
- package/dist/roster/conversation-agents.d.ts +21 -1
- package/dist/roster/conversation-agents.js +47 -2
- package/dist/roster.d.ts +5 -2
- package/dist/roster.js +16 -5
- package/dist/schedules.d.ts +178 -0
- package/dist/schedules.js +182 -0
- package/dist/subagents/client.js +64 -20
- package/dist/subagents/contract.d.ts +123 -10
- package/dist/subagents/contract.js +38 -5
- package/dist/subagents/host.d.ts +2 -4
- package/dist/subagents/host.js +152 -22
- package/docs/api/README.md +1 -0
- package/docs/api/goal/contract.md +1 -1
- package/docs/api/goal/host.md +22 -1
- package/docs/api/loop/contract.md +43 -31
- package/docs/api/question/client.md +2 -1
- package/docs/api/question/contract.md +183 -51
- package/docs/api/question/host.md +30 -0
- package/docs/api/roster/conversation-agents.md +34 -8
- package/docs/api/roster.md +39 -2
- package/docs/api/schedules.md +481 -0
- package/docs/api/subagents/contract.md +101 -28
- package/docs/api/subagents/host.md +5 -3
- package/package.json +9 -6
package/dist/question/host.js
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
import { nanoid } from "@fungi.computer/cairn";
|
|
2
2
|
import { Mycelium } from "@fungi.computer/mycelium";
|
|
3
|
-
import { defineCollection, defineExtension, ExtensionCommandError, } from "@fungi.computer/shiitake/extensions";
|
|
3
|
+
import { defineCollection, defineExtension, ExtensionCommandError, AttentionRecipientSchema, } from "@fungi.computer/shiitake/extensions";
|
|
4
4
|
import * as Clock from "effect/Clock";
|
|
5
5
|
import * as Effect from "effect/Effect";
|
|
6
6
|
import * as Option from "effect/Option";
|
|
7
7
|
import * as Schema from "effect/Schema";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import { z } from "zod";
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { questionContract, OpenAsk, Questions, Answers, HumanResult, DeclineInput, ClosedStatus, AnswerFlowInput, } from "./contract.js";
|
|
10
|
+
import { catalog, done } from "../internal/catalog.js";
|
|
11
|
+
import { questionContract, OpenAsk, Questions, Answers, HumanResult, AgentAnswerResult, AnsweredBy, EscalateResult, DeclineInput, ClosedStatus, AnswerFlowInput, } from "./contract.js";
|
|
12
|
+
import { advanceAnswerFlow } from "./answer-flow.js";
|
|
14
13
|
const config = {
|
|
15
14
|
maximumOpen: 4,
|
|
16
15
|
liveWindowMs: 600_000,
|
|
16
|
+
// No Agent holds a routed ask forever: past this a person decides.
|
|
17
|
+
routedEscalationMs: 900_000,
|
|
18
|
+
routesToSender: false,
|
|
17
19
|
idle: "Need a person's decision? forage.describe({path:'question'}) first.",
|
|
18
20
|
guide: "Question tools: ask, withdraw. 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.",
|
|
19
|
-
askDescription: "Ask a person one or more questions and wait for the answer; options follow the question module rules.",
|
|
21
|
+
askDescription: "Ask a person one or more questions and wait for the answer; options follow the question module rules. When another Agent sent you this work, your question goes to that Agent first and always parks: end your turn, and its answer arrives as a follow-up.",
|
|
20
22
|
};
|
|
21
23
|
const Response = Schema.Union([
|
|
22
24
|
Schema.Struct({ status: Schema.Literal("answered"), answers: Answers }),
|
|
25
|
+
Schema.Struct({
|
|
26
|
+
status: Schema.Literal("answered"),
|
|
27
|
+
answers: Answers,
|
|
28
|
+
answeredBy: AnsweredBy,
|
|
29
|
+
}),
|
|
23
30
|
Schema.Struct({
|
|
24
31
|
status: Schema.Literal("declined"),
|
|
25
32
|
note: DeclineInput.fields.note,
|
|
@@ -36,6 +43,7 @@ const Response = Schema.Union([
|
|
|
36
43
|
]);
|
|
37
44
|
const AskRow = Schema.Struct({
|
|
38
45
|
askId: OpenAsk.fields.askId,
|
|
46
|
+
waitingOn: Schema.optional(AttentionRecipientSchema),
|
|
39
47
|
questions: Questions,
|
|
40
48
|
status: OpenAsk.fields.status,
|
|
41
49
|
createdAt: OpenAsk.fields.createdAt,
|
|
@@ -82,7 +90,7 @@ const Receipt = Schema.Struct({
|
|
|
82
90
|
sessionId: Schema.String,
|
|
83
91
|
requestId: Schema.String,
|
|
84
92
|
payload: Schema.String,
|
|
85
|
-
result:
|
|
93
|
+
result: AgentAnswerResult,
|
|
86
94
|
});
|
|
87
95
|
const collections = {
|
|
88
96
|
deliveries: defineCollection(Schema.Struct({
|
|
@@ -164,6 +172,8 @@ function closeAsk(ctx, row, outcome, followUpWorkId = null, toolCallId) {
|
|
|
164
172
|
yield* ctx.state.asks.remove(row.askId);
|
|
165
173
|
if (row.expiry !== null)
|
|
166
174
|
yield* ctx.timers.cancel(`ask:${row.askId}`);
|
|
175
|
+
if (row.waitingOn !== undefined)
|
|
176
|
+
yield* ctx.timers.cancel(routeTimer(row.askId));
|
|
167
177
|
yield* ctx.attention.clear(row.sessionId, row.askId);
|
|
168
178
|
yield* ctx.observe(row.sessionId);
|
|
169
179
|
});
|
|
@@ -280,13 +290,16 @@ function settle(ctx, run) {
|
|
|
280
290
|
});
|
|
281
291
|
}
|
|
282
292
|
function project(row) {
|
|
283
|
-
|
|
293
|
+
const open = {
|
|
284
294
|
askId: row.askId,
|
|
285
295
|
questions: row.questions,
|
|
286
296
|
status: row.status,
|
|
287
297
|
createdAt: row.createdAt,
|
|
288
298
|
expiresAt: row.expiry?.at ?? null,
|
|
289
299
|
};
|
|
300
|
+
return row.waitingOn === undefined
|
|
301
|
+
? open
|
|
302
|
+
: { ...open, waitingOn: row.waitingOn };
|
|
290
303
|
}
|
|
291
304
|
const encoder = new TextEncoder();
|
|
292
305
|
/** Keep every question and selection represented within the follow-up byte budget. */
|
|
@@ -302,7 +315,9 @@ function clip(text, budget) {
|
|
|
302
315
|
}
|
|
303
316
|
function responsePrompt(row, response) {
|
|
304
317
|
const prefix = response.status === "answered"
|
|
305
|
-
?
|
|
318
|
+
? "answeredBy" in response
|
|
319
|
+
? `Answer from Agent ${response.answeredBy.agentId} to ask ${row.askId}:\n`
|
|
320
|
+
: `Answer to ask ${row.askId}:\n`
|
|
306
321
|
: response.status === "declined"
|
|
307
322
|
? `Declined ask ${row.askId}:\n${response.note === undefined ? "" : `Note: ${clip(response.note, 2_048)}\n`}`
|
|
308
323
|
: `Ask ${row.askId} expired; ${response.action === "stop" ? "do not proceed with this decision" : "recommended default"}:\n`;
|
|
@@ -343,6 +358,8 @@ function deliverResponse(ctx, row, response) {
|
|
|
343
358
|
requireExistingSession: true,
|
|
344
359
|
when: "now",
|
|
345
360
|
correlation: row.askId,
|
|
361
|
+
// The continued work keeps its sender, so a second question still waits on that Agent.
|
|
362
|
+
continuesWorkId: row.workId,
|
|
346
363
|
prompt: {
|
|
347
364
|
text: responsePrompt(row, response),
|
|
348
365
|
requestId: `ask:${row.askId}`,
|
|
@@ -357,7 +374,11 @@ function deliverResponse(ctx, row, response) {
|
|
|
357
374
|
: { status: "undeliverable", reason: delivered.reason };
|
|
358
375
|
yield* closeAsk(ctx, row, outcome, delivered.status === "sent" ? delivered.workId : null);
|
|
359
376
|
return delivered.status === "sent"
|
|
360
|
-
? {
|
|
377
|
+
? {
|
|
378
|
+
result: "accepted",
|
|
379
|
+
via: "follow_up",
|
|
380
|
+
commandId: delivered.workId,
|
|
381
|
+
}
|
|
361
382
|
: { result: "closed", status: "undeliverable" };
|
|
362
383
|
});
|
|
363
384
|
}
|
|
@@ -377,6 +398,13 @@ function expireAsk(ctx, row, now) {
|
|
|
377
398
|
return Effect.gen(function* () {
|
|
378
399
|
if (row.expiry === null || now < row.expiry.at || row.pending !== null)
|
|
379
400
|
return undefined;
|
|
401
|
+
if (row.waitingOn !== undefined) {
|
|
402
|
+
// Nobody answered in time. A person decides next; the default never
|
|
403
|
+
// stands in for a decision no person has seen.
|
|
404
|
+
yield* ctx.timers.cancel(`ask:${row.askId}`);
|
|
405
|
+
yield* escalateAsk(ctx, { ...row, expiry: null });
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
380
408
|
const response = row.expiry.action === "stop"
|
|
381
409
|
? { status: "expired", action: "stop" }
|
|
382
410
|
: {
|
|
@@ -409,15 +437,21 @@ function validAnswers(questions, values) {
|
|
|
409
437
|
return valid;
|
|
410
438
|
}
|
|
411
439
|
function responseIdentity(input) {
|
|
412
|
-
const response = input.kind === "answer"
|
|
413
|
-
?
|
|
440
|
+
const response = input.kind === "answer" || input.kind === "agent_answer"
|
|
441
|
+
? input.kind === "agent_answer"
|
|
442
|
+
? {
|
|
443
|
+
status: "answered",
|
|
444
|
+
answers: input.answers,
|
|
445
|
+
answeredBy: { kind: "agent", agentId: input.caller.agentId },
|
|
446
|
+
}
|
|
447
|
+
: { status: "answered", answers: input.answers }
|
|
414
448
|
: input.note === undefined
|
|
415
449
|
? { status: "declined" }
|
|
416
450
|
: { status: "declined", note: input.note };
|
|
417
451
|
const payload = JSON.stringify([
|
|
418
452
|
input.kind,
|
|
419
453
|
input.askId,
|
|
420
|
-
input.kind === "answer"
|
|
454
|
+
input.kind === "answer" || input.kind === "agent_answer"
|
|
421
455
|
? Object.entries(input.answers)
|
|
422
456
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
423
457
|
.map(([id, answer]) => [
|
|
@@ -426,6 +460,9 @@ function responseIdentity(input) {
|
|
|
426
460
|
answer.text ?? null,
|
|
427
461
|
])
|
|
428
462
|
: (input.note ?? null),
|
|
463
|
+
input.kind === "agent_answer"
|
|
464
|
+
? [input.caller.agentId, input.caller.sessionId]
|
|
465
|
+
: null,
|
|
429
466
|
]);
|
|
430
467
|
return { response, payload };
|
|
431
468
|
}
|
|
@@ -467,7 +504,12 @@ function respond(ctx, sessionId, input) {
|
|
|
467
504
|
const row = found.value;
|
|
468
505
|
if (row.pending !== null)
|
|
469
506
|
return yield* record({ result: "closed", status: row.pending.status });
|
|
470
|
-
|
|
507
|
+
// A person may always answer; an Agent only when the ask waits on it.
|
|
508
|
+
if (input.kind === "agent_answer" &&
|
|
509
|
+
!waitsOn(row, input.caller.agentId, input.caller.sessionId))
|
|
510
|
+
// A refusal changes nothing, so it must not spend the request id.
|
|
511
|
+
return { result: "refused", reason: "not_sender" };
|
|
512
|
+
if (input.kind === "answer" || input.kind === "agent_answer") {
|
|
471
513
|
const valid = validAnswers(row.questions, input.answers);
|
|
472
514
|
if (!valid)
|
|
473
515
|
return yield* Effect.fail(new ExtensionCommandError({
|
|
@@ -478,6 +520,80 @@ function respond(ctx, sessionId, input) {
|
|
|
478
520
|
return yield* record(yield* offerResponse(ctx, row, response));
|
|
479
521
|
});
|
|
480
522
|
}
|
|
523
|
+
/** Whether this ask waits on exactly this Agent Session. */
|
|
524
|
+
function waitsOn(row, agentId, sessionId) {
|
|
525
|
+
return (row.waitingOn?.agentId === agentId && row.waitingOn.sessionId === sessionId);
|
|
526
|
+
}
|
|
527
|
+
function respondPerson(ctx, sessionId, input) {
|
|
528
|
+
return respond(ctx, sessionId, input).pipe(Effect.flatMap((result) => result.result === "refused"
|
|
529
|
+
? Effect.fail(new ExtensionCommandError({
|
|
530
|
+
reason: "rejected",
|
|
531
|
+
message: "A person's response cannot be refused",
|
|
532
|
+
}))
|
|
533
|
+
: Effect.succeed(result)));
|
|
534
|
+
}
|
|
535
|
+
/** The attention item for one open ask, waiting on an Agent or a person. */
|
|
536
|
+
function askAttention(row) {
|
|
537
|
+
const item = {
|
|
538
|
+
id: row.askId,
|
|
539
|
+
kind: "question",
|
|
540
|
+
summary: row.questions.map((question) => question.header).join(", "),
|
|
541
|
+
since: row.createdAt,
|
|
542
|
+
};
|
|
543
|
+
const timed = row.expiry === null ? item : { ...item, expiresAt: row.expiry.at };
|
|
544
|
+
return row.waitingOn === undefined
|
|
545
|
+
? timed
|
|
546
|
+
: { ...timed, waitingOn: row.waitingOn };
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* The durable backstop of an ask that waits on an Agent. It is separate from
|
|
550
|
+
* the asker's own expiry, so it always hands the ask to a person and never
|
|
551
|
+
* applies a default answer.
|
|
552
|
+
*/
|
|
553
|
+
function routeTimer(askId) {
|
|
554
|
+
return `route:${askId}`;
|
|
555
|
+
}
|
|
556
|
+
/** Hand an ask to a person: it stops waiting on any Agent and needs them. */
|
|
557
|
+
function escalateAsk(ctx, row) {
|
|
558
|
+
return Effect.gen(function* () {
|
|
559
|
+
const moved = { ...row, waitingOn: undefined };
|
|
560
|
+
yield* ctx.timers.cancel(routeTimer(row.askId));
|
|
561
|
+
yield* ctx.state.asks.put(moved);
|
|
562
|
+
yield* ctx.attention.set(row.sessionId, askAttention(moved));
|
|
563
|
+
yield* ctx.observe(row.sessionId);
|
|
564
|
+
return moved;
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
function escalateFromAgent(ctx, sessionId, askId, caller) {
|
|
568
|
+
return Effect.gen(function* () {
|
|
569
|
+
const found = yield* ctx.state.asks.get(askId);
|
|
570
|
+
if (Option.isNone(found) || found.value.sessionId !== sessionId) {
|
|
571
|
+
const closed = yield* ctx.state.closed.get(askId);
|
|
572
|
+
const missing = Option.isSome(closed) && closed.value.sessionId === sessionId
|
|
573
|
+
? { result: "closed", status: closed.value.outcome.status }
|
|
574
|
+
: { result: "not_found" };
|
|
575
|
+
return missing;
|
|
576
|
+
}
|
|
577
|
+
const row = found.value;
|
|
578
|
+
if (row.pending !== null) {
|
|
579
|
+
const answered = {
|
|
580
|
+
result: "closed",
|
|
581
|
+
status: row.pending.status,
|
|
582
|
+
};
|
|
583
|
+
return answered;
|
|
584
|
+
}
|
|
585
|
+
if (!waitsOn(row, caller.agentId, caller.sessionId)) {
|
|
586
|
+
const refused = {
|
|
587
|
+
result: "refused",
|
|
588
|
+
reason: "not_sender",
|
|
589
|
+
};
|
|
590
|
+
return refused;
|
|
591
|
+
}
|
|
592
|
+
yield* escalateAsk(ctx, row);
|
|
593
|
+
const escalated = { result: "escalated", to: "person" };
|
|
594
|
+
return escalated;
|
|
595
|
+
});
|
|
596
|
+
}
|
|
481
597
|
// Mycelium's maintained operation interface uses Zod. The owner additionally
|
|
482
598
|
// decodes Questions for shared domain refinements and durable state.
|
|
483
599
|
const baseInput = z.strictObject({
|
|
@@ -592,6 +708,14 @@ const ToolResult = z.union([
|
|
|
592
708
|
status: z.literal("answered"),
|
|
593
709
|
answers: toolAnswers,
|
|
594
710
|
}),
|
|
711
|
+
z.strictObject({
|
|
712
|
+
status: z.literal("answered"),
|
|
713
|
+
answers: toolAnswers,
|
|
714
|
+
answeredBy: z.strictObject({
|
|
715
|
+
kind: z.literal("parent"),
|
|
716
|
+
agentId: z.string().min(1).max(256),
|
|
717
|
+
}),
|
|
718
|
+
}),
|
|
595
719
|
z.strictObject({ status: z.literal("declined"), note: z.string() }),
|
|
596
720
|
z.strictObject({ status: z.literal("declined") }),
|
|
597
721
|
z.strictObject({ status: z.literal("expired"), action: z.literal("stop") }),
|
|
@@ -641,113 +765,16 @@ function answerNotice(result) {
|
|
|
641
765
|
}
|
|
642
766
|
function chooseQuestion(ctx, sessionId, askId) {
|
|
643
767
|
return Effect.gen(function* () {
|
|
644
|
-
if (askId
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
type: "choice",
|
|
654
|
-
message: "Which question would you like to answer?",
|
|
655
|
-
options: rows.map((row) => ({
|
|
656
|
-
value: row.askId,
|
|
657
|
-
label: row.questions
|
|
658
|
-
.map((question) => question.header)
|
|
659
|
-
.join(" · "),
|
|
660
|
-
})),
|
|
661
|
-
}, {}, "askId");
|
|
662
|
-
const first = rows[0];
|
|
663
|
-
if (first === undefined)
|
|
664
|
-
throw new Error("Expected the waiting question");
|
|
665
|
-
return { type: "selected", askId: first.askId };
|
|
666
|
-
}
|
|
667
|
-
return { type: "selected", askId };
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
function multipleSelection(question, selection) {
|
|
671
|
-
const parsed = Papa.parse(selection, {
|
|
672
|
-
delimiter: ",",
|
|
673
|
-
skipEmptyLines: true,
|
|
768
|
+
if (askId !== undefined)
|
|
769
|
+
return { type: "selected", askId };
|
|
770
|
+
const first = (yield* ctx.state.asks.page({
|
|
771
|
+
index: sessionId,
|
|
772
|
+
limit: ctx.config.maximumOpen,
|
|
773
|
+
})).find((row) => row.pending === null);
|
|
774
|
+
return first === undefined
|
|
775
|
+
? done({ type: "info", message: "No questions are waiting." })
|
|
776
|
+
: { type: "selected", askId: first.askId };
|
|
674
777
|
});
|
|
675
|
-
const selected = parsed.data[0] ?? [];
|
|
676
|
-
const offered = new Set(question.options.map((option) => option.label));
|
|
677
|
-
if (question.multiple !== true ||
|
|
678
|
-
parsed.errors.length > 0 ||
|
|
679
|
-
parsed.data.length > 1 ||
|
|
680
|
-
new Set(selected).size !== selected.length ||
|
|
681
|
-
selected.some((label) => !offered.has(label)))
|
|
682
|
-
return Effect.fail(new ExtensionCommandError({
|
|
683
|
-
reason: "invalid",
|
|
684
|
-
message: "Enter offered labels once each as a comma list; quote labels containing commas",
|
|
685
|
-
}));
|
|
686
|
-
return Effect.succeed(selected);
|
|
687
|
-
}
|
|
688
|
-
function singleSelection(question, selection) {
|
|
689
|
-
if (selection === "free_text")
|
|
690
|
-
return Effect.succeed([]);
|
|
691
|
-
const option = question.options.find((option) => `option:${option.label}` === selection);
|
|
692
|
-
return option === undefined
|
|
693
|
-
? Effect.fail(new ExtensionCommandError({
|
|
694
|
-
reason: "invalid",
|
|
695
|
-
message: "Choose an offered option or free text",
|
|
696
|
-
}))
|
|
697
|
-
: Effect.succeed([option.label]);
|
|
698
|
-
}
|
|
699
|
-
/**
|
|
700
|
-
* The Agent's own words are the title only when they fit one; a longer
|
|
701
|
-
* question gets a plain title and sits in the body, whole in the details.
|
|
702
|
-
*/
|
|
703
|
-
function questionHeading(question) {
|
|
704
|
-
const text = question.question.replace(/\s+/gu, " ").trim();
|
|
705
|
-
if (text.length <= titleLimit)
|
|
706
|
-
return { message: text };
|
|
707
|
-
return {
|
|
708
|
-
message: "The Agent has a question",
|
|
709
|
-
body: question.question.slice(0, 4_000),
|
|
710
|
-
bodyLabel: "Question",
|
|
711
|
-
details: [{ label: "Question", value: question.question.slice(0, 4_000) }],
|
|
712
|
-
};
|
|
713
|
-
}
|
|
714
|
-
function promptQuestion(nextQuestion, next) {
|
|
715
|
-
const heading = questionHeading(nextQuestion);
|
|
716
|
-
if (nextQuestion.multiple === true)
|
|
717
|
-
return ask({
|
|
718
|
-
type: "text",
|
|
719
|
-
...heading,
|
|
720
|
-
summary: `Separate your picks with commas, or leave blank to write your own.${nextQuestion.options.some((option) => option.label.includes(","))
|
|
721
|
-
? " Put quotes around a pick that contains a comma."
|
|
722
|
-
: ""}`,
|
|
723
|
-
facts: [
|
|
724
|
-
{
|
|
725
|
-
label: "Options",
|
|
726
|
-
value: nextQuestion.options
|
|
727
|
-
.map((option) => option.label)
|
|
728
|
-
.join(" · "),
|
|
729
|
-
},
|
|
730
|
-
],
|
|
731
|
-
details: [
|
|
732
|
-
...("details" in heading ? heading.details : []),
|
|
733
|
-
...nextQuestion.options.map((option) => ({
|
|
734
|
-
label: option.label.slice(0, 200),
|
|
735
|
-
value: option.description,
|
|
736
|
-
})),
|
|
737
|
-
].slice(0, 24),
|
|
738
|
-
}, { stage: "multiple", ...next }, "selection");
|
|
739
|
-
return ask({
|
|
740
|
-
type: "choice",
|
|
741
|
-
...heading,
|
|
742
|
-
options: [
|
|
743
|
-
...nextQuestion.options.map((option) => ({
|
|
744
|
-
value: `option:${option.label}`,
|
|
745
|
-
label: option.label,
|
|
746
|
-
description: option.description,
|
|
747
|
-
})),
|
|
748
|
-
{ value: "free_text", label: "Write an answer" },
|
|
749
|
-
],
|
|
750
|
-
}, { stage: "selection", ...next }, "selection");
|
|
751
778
|
}
|
|
752
779
|
function answerFlow(ctx, sessionId, input) {
|
|
753
780
|
return Effect.gen(function* () {
|
|
@@ -769,73 +796,20 @@ function answerFlow(ctx, sessionId, input) {
|
|
|
769
796
|
/** Progress the selected Ask's draft; request replay still belongs to respond. */
|
|
770
797
|
function answerQuestion(ctx, sessionId, row, input) {
|
|
771
798
|
return Effect.gen(function* () {
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
:
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
message: "Unknown question step",
|
|
787
|
-
}));
|
|
788
|
-
let answer;
|
|
789
|
-
if (draft.stage === "text")
|
|
790
|
-
answer =
|
|
791
|
-
draft.text.length === 0
|
|
792
|
-
? { selected: draft.selected }
|
|
793
|
-
: { selected: draft.selected, text: draft.text };
|
|
794
|
-
else if (draft.stage === "selection" || draft.stage === "multiple") {
|
|
795
|
-
const selected = yield* draft.stage === "multiple"
|
|
796
|
-
? multipleSelection(question, draft.selection)
|
|
797
|
-
: singleSelection(question, draft.selection);
|
|
798
|
-
// A single choice is complete; only multiple selection and free text
|
|
799
|
-
// need an explicit submit.
|
|
800
|
-
if (draft.stage === "multiple" || selected.length === 0)
|
|
801
|
-
return ask({
|
|
802
|
-
type: "text",
|
|
803
|
-
message: selected.length === 0
|
|
804
|
-
? `Your answer for ${question.header}:`
|
|
805
|
-
: `Additional context for ${question.header} (leave blank to skip):`,
|
|
806
|
-
}, {
|
|
807
|
-
stage: "text",
|
|
808
|
-
askId: row.askId,
|
|
809
|
-
requestId,
|
|
810
|
-
index,
|
|
811
|
-
answers,
|
|
812
|
-
selected,
|
|
813
|
-
}, "text");
|
|
814
|
-
answer = { selected };
|
|
815
|
-
}
|
|
816
|
-
if (answer !== undefined) {
|
|
817
|
-
answers = { ...answers, [question.id]: answer };
|
|
818
|
-
index += 1;
|
|
819
|
-
if (index === row.questions.length) {
|
|
820
|
-
const result = yield* respond(ctx, sessionId, {
|
|
821
|
-
kind: "answer",
|
|
822
|
-
askId: row.askId,
|
|
823
|
-
requestId,
|
|
824
|
-
answers,
|
|
825
|
-
});
|
|
826
|
-
return answerNotice(result);
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
if (row.status === "settled" || row.pending !== null)
|
|
830
|
-
return done({
|
|
831
|
-
type: "info",
|
|
832
|
-
message: "That question is already closed.",
|
|
833
|
-
});
|
|
834
|
-
const next = { askId: row.askId, requestId, index, answers };
|
|
835
|
-
const nextQuestion = row.questions[index];
|
|
836
|
-
if (nextQuestion === undefined)
|
|
837
|
-
throw new Error("Expected the next question");
|
|
838
|
-
return promptQuestion(nextQuestion, next);
|
|
799
|
+
const outcome = yield* advanceAnswerFlow({
|
|
800
|
+
askId: row.askId,
|
|
801
|
+
questions: row.questions,
|
|
802
|
+
closed: row.status === "settled" || row.pending !== null,
|
|
803
|
+
}, input);
|
|
804
|
+
if (outcome.type === "step")
|
|
805
|
+
return outcome.step;
|
|
806
|
+
const result = yield* respondPerson(ctx, sessionId, {
|
|
807
|
+
kind: "answer",
|
|
808
|
+
askId: outcome.askId,
|
|
809
|
+
requestId: outcome.requestId,
|
|
810
|
+
answers: outcome.answers,
|
|
811
|
+
});
|
|
812
|
+
return answerNotice(result);
|
|
839
813
|
});
|
|
840
814
|
}
|
|
841
815
|
function replayQuestion(ctx, open, workId, digest, toolCallId) {
|
|
@@ -884,163 +858,198 @@ function answerCatalog(available) {
|
|
|
884
858
|
status: "unavailable",
|
|
885
859
|
reason: "There is no waiting question to answer.",
|
|
886
860
|
},
|
|
887
|
-
projections: {
|
|
888
|
-
slash: { aliases: ["answer"], order: 5 },
|
|
889
|
-
palette: { order: 5 },
|
|
890
|
-
help: { order: 5 },
|
|
891
|
-
},
|
|
892
861
|
},
|
|
893
862
|
]);
|
|
894
863
|
}
|
|
895
864
|
/** One durable question owner over the ordinary extension doors. */
|
|
896
|
-
export
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
.
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
865
|
+
export function questionExtensionFor(routesToSender) {
|
|
866
|
+
const extensionConfig = { ...config, routesToSender };
|
|
867
|
+
return defineExtension({
|
|
868
|
+
id: questionContract.id,
|
|
869
|
+
version: "2.0.0",
|
|
870
|
+
})
|
|
871
|
+
.config(extensionConfig)
|
|
872
|
+
.state({ version: 1, migrations: {}, collections })
|
|
873
|
+
.define({
|
|
874
|
+
catalog: answerCatalog(false),
|
|
875
|
+
attention: true,
|
|
876
|
+
timers: true,
|
|
877
|
+
hooks: {
|
|
878
|
+
onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run), { discard: true }),
|
|
879
|
+
onTimer: (ctx, timer) => Effect.gen(function* () {
|
|
880
|
+
if (timer.key.startsWith("route:")) {
|
|
881
|
+
const row = yield* ctx.state.asks.get(timer.key.slice(6));
|
|
882
|
+
if (Option.isSome(row) &&
|
|
883
|
+
row.value.waitingOn !== undefined &&
|
|
884
|
+
row.value.pending === null)
|
|
885
|
+
yield* escalateAsk(ctx, row.value);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (!timer.key.startsWith("ask:"))
|
|
889
|
+
return;
|
|
890
|
+
const row = yield* ctx.state.asks.get(timer.key.slice(4));
|
|
891
|
+
if (Option.isSome(row))
|
|
892
|
+
yield* expireAsk(ctx, row.value, timer.firedAt);
|
|
893
|
+
}),
|
|
894
|
+
},
|
|
895
|
+
prompt: (ctx) => [{ name: "question", text: ctx.config.idle }],
|
|
896
|
+
projection: {
|
|
897
|
+
schema: questionContract.projection.schema,
|
|
898
|
+
catalog: (value) => answerCatalog(value.asks.length > 0),
|
|
899
|
+
read: (ctx, sessionId) => Option.some({
|
|
900
|
+
asks: ctx.state.asks
|
|
901
|
+
.peekPage({ index: sessionId, limit: ctx.config.maximumOpen })
|
|
902
|
+
.filter((row) => row.pending === null)
|
|
903
|
+
.map(project),
|
|
904
|
+
}),
|
|
905
|
+
},
|
|
906
|
+
commands: {
|
|
907
|
+
specs: questionContract.commands,
|
|
908
|
+
handlers: (ctx) => ({
|
|
909
|
+
answerFlow: ({ sessionId, input }) => answerFlow(ctx, sessionId, input),
|
|
910
|
+
answer: ({ sessionId, input }) => respondPerson(ctx, sessionId, { kind: "answer", ...input }),
|
|
911
|
+
answerAgent: ({ sessionId, input, caller }) => caller === undefined
|
|
912
|
+
? Effect.fail(new ExtensionCommandError({
|
|
913
|
+
reason: "rejected",
|
|
914
|
+
message: "Only an authenticated Agent can answer as an Agent",
|
|
915
|
+
}))
|
|
916
|
+
: respond(ctx, sessionId, {
|
|
917
|
+
kind: "agent_answer",
|
|
918
|
+
...input,
|
|
919
|
+
caller,
|
|
920
|
+
}).pipe(Effect.map((result) => ({
|
|
921
|
+
result,
|
|
922
|
+
answeredBy: {
|
|
923
|
+
kind: "agent",
|
|
924
|
+
agentId: caller.agentId,
|
|
925
|
+
},
|
|
926
|
+
}))),
|
|
927
|
+
escalateAgent: ({ sessionId, input, caller }) => Effect.gen(function* () {
|
|
928
|
+
if (caller === undefined)
|
|
929
|
+
return yield* Effect.fail(new ExtensionCommandError({
|
|
930
|
+
reason: "rejected",
|
|
931
|
+
message: "Only an authenticated Agent can escalate an Ask",
|
|
932
|
+
}));
|
|
933
|
+
return yield* escalateFromAgent(ctx, sessionId, input.askId, caller);
|
|
953
934
|
}),
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
935
|
+
decline: ({ sessionId, input }) => respondPerson(ctx, sessionId, { kind: "decline", ...input }),
|
|
936
|
+
list: ({ sessionId }) => ctx.state.asks
|
|
937
|
+
.page({ index: sessionId, limit: ctx.config.maximumOpen })
|
|
938
|
+
.pipe(Effect.map((rows) => ({
|
|
939
|
+
asks: rows.filter((row) => row.pending === null).map(project),
|
|
940
|
+
}))),
|
|
941
|
+
}),
|
|
942
|
+
},
|
|
943
|
+
tools: (ctx, { sessionId, workId }) => {
|
|
944
|
+
const runAttemptId = nanoid();
|
|
945
|
+
return Mycelium.module({
|
|
946
|
+
id: "botanical.session-question",
|
|
947
|
+
name: "question",
|
|
948
|
+
description: ctx.config.guide,
|
|
949
|
+
operations: {
|
|
950
|
+
withdraw: Mycelium.operation({
|
|
951
|
+
replay: "unsafe",
|
|
952
|
+
description: "Withdraw a question that no longer needs a human answer. Repeating withdrawal preserves its closed outcome.",
|
|
953
|
+
input: z.strictObject({ askId: z.string().min(1).max(256) }),
|
|
954
|
+
output: WithdrawResult,
|
|
955
|
+
execute: async ({ askId }) => WithdrawResult.parse(await Effect.runPromise(ctx.transact(withdraw(ctx, sessionId, askId)))),
|
|
956
|
+
}),
|
|
957
|
+
ask: Mycelium.operation({
|
|
958
|
+
replay: "unsafe",
|
|
959
|
+
description: ctx.config.askDescription,
|
|
960
|
+
input,
|
|
961
|
+
output: ToolResult,
|
|
962
|
+
execute: async (value, invocation) => {
|
|
963
|
+
const created = await Effect.runPromise(ctx.transact(Effect.gen(function* () {
|
|
964
|
+
const questions = yield* Schema.decodeUnknownEffect(Questions)(value.questions).pipe(Effect.mapError(() => new ExtensionCommandError({
|
|
965
|
+
reason: "invalid",
|
|
966
|
+
message: "Invalid question identifiers or option labels",
|
|
967
|
+
})));
|
|
968
|
+
const digest = questionDigest(sessionId, workId, questions);
|
|
969
|
+
const rows = yield* ctx.state.asks.page({
|
|
970
|
+
index: sessionId,
|
|
971
|
+
limit: ctx.config.maximumOpen,
|
|
972
|
+
});
|
|
973
|
+
const open = yield* Effect.forEach(rows, (row) => row.workId === workId &&
|
|
974
|
+
row.status === "live" &&
|
|
975
|
+
row.runAttemptId !== runAttemptId
|
|
976
|
+
? parkAsk(ctx, row)
|
|
977
|
+
: Effect.succeed(row));
|
|
978
|
+
const replay = yield* replayQuestion(ctx, open, workId, digest, invocation.toolCallId);
|
|
979
|
+
if (replay !== undefined)
|
|
980
|
+
return replay;
|
|
981
|
+
if (open.length === ctx.config.maximumOpen)
|
|
982
|
+
return {
|
|
983
|
+
status: "refused",
|
|
984
|
+
reason: "session_limit",
|
|
985
|
+
askIds: open.map((row) => row.askId),
|
|
986
|
+
};
|
|
987
|
+
const now = yield* Clock.currentTimeMillis;
|
|
988
|
+
const window = invocation.deadlineAt === null
|
|
989
|
+
? 0
|
|
990
|
+
: Math.max(0, Math.min(ctx.config.liveWindowMs, invocation.deadlineAt - now - deadlineReserveMs));
|
|
991
|
+
// The Agent that sent this work answers later; a live wait
|
|
992
|
+
// would stall the turn it may itself be waiting on.
|
|
993
|
+
const waitingOn = ctx.config.routesToSender
|
|
994
|
+
? yield* ctx.workSender({ sessionId, workId })
|
|
995
|
+
: null;
|
|
996
|
+
const live = value.mode === "wait" &&
|
|
997
|
+
window > 0 &&
|
|
998
|
+
waitingOn === null;
|
|
999
|
+
const existingLive = open.filter((row) => row.workId === workId && row.status === "live");
|
|
1000
|
+
if (live && existingLive.length > 0)
|
|
1001
|
+
return {
|
|
1002
|
+
status: "refused",
|
|
1003
|
+
reason: "live_limit",
|
|
1004
|
+
askIds: existingLive.map((row) => row.askId),
|
|
1005
|
+
};
|
|
1006
|
+
const row = {
|
|
1007
|
+
askId: nanoid(),
|
|
1008
|
+
waitingOn: waitingOn ?? undefined,
|
|
1009
|
+
sessionId,
|
|
1010
|
+
workId,
|
|
1011
|
+
digest,
|
|
1012
|
+
questions,
|
|
1013
|
+
status: live ? "live" : "parked",
|
|
1014
|
+
runAttemptId: live ? runAttemptId : null,
|
|
1015
|
+
pending: null,
|
|
1016
|
+
createdAt: now,
|
|
1017
|
+
expiry: "onExpire" in value
|
|
1018
|
+
? {
|
|
1019
|
+
at: now + value.onExpire.afterMinutes * 60_000,
|
|
1020
|
+
action: value.onExpire.action,
|
|
1021
|
+
}
|
|
1022
|
+
: null,
|
|
995
1023
|
};
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
createdAt: now,
|
|
1006
|
-
expiry: "onExpire" in value
|
|
1024
|
+
yield* ctx.state.asks.put(row);
|
|
1025
|
+
if (row.expiry !== null)
|
|
1026
|
+
yield* ctx.timers.set(`ask:${row.askId}`, row.expiry.at);
|
|
1027
|
+
// Whatever the asker chose, an ask nobody takes up reaches a person.
|
|
1028
|
+
if (row.waitingOn !== undefined)
|
|
1029
|
+
yield* ctx.timers.set(routeTimer(row.askId), now + ctx.config.routedEscalationMs);
|
|
1030
|
+
yield* ctx.attention.set(sessionId, askAttention(row));
|
|
1031
|
+
yield* ctx.observe(sessionId);
|
|
1032
|
+
return live
|
|
1007
1033
|
? {
|
|
1008
|
-
|
|
1009
|
-
|
|
1034
|
+
status: "waiting",
|
|
1035
|
+
askId: row.askId,
|
|
1036
|
+
until: now + window,
|
|
1010
1037
|
}
|
|
1011
|
-
:
|
|
1012
|
-
};
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
? {
|
|
1030
|
-
status: "waiting",
|
|
1031
|
-
askId: row.askId,
|
|
1032
|
-
until: now + window,
|
|
1033
|
-
}
|
|
1034
|
-
: { status: "parked", askId: row.askId };
|
|
1035
|
-
})));
|
|
1036
|
-
const result = created.status === "waiting"
|
|
1037
|
-
? await waitForResponse(ctx, created.askId, created.until, invocation)
|
|
1038
|
-
: created;
|
|
1039
|
-
// Zod owns the JSON output representation, including mutable arrays.
|
|
1040
|
-
return ToolResult.parse(result);
|
|
1041
|
-
},
|
|
1042
|
-
}),
|
|
1043
|
-
},
|
|
1044
|
-
});
|
|
1045
|
-
},
|
|
1046
|
-
});
|
|
1038
|
+
: { status: "parked", askId: row.askId };
|
|
1039
|
+
})));
|
|
1040
|
+
const result = created.status === "waiting"
|
|
1041
|
+
? await waitForResponse(ctx, created.askId, created.until, invocation)
|
|
1042
|
+
: created;
|
|
1043
|
+
// Zod owns the JSON output representation, including mutable arrays.
|
|
1044
|
+
return ToolResult.parse(result);
|
|
1045
|
+
},
|
|
1046
|
+
}),
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
/** Hub's shared roster sends every Ask to a person. */
|
|
1053
|
+
export const questionExtension = questionExtensionFor(false);
|
|
1054
|
+
/** Local's roster sends an Ask to the Agent that sent the asking work first. */
|
|
1055
|
+
export const localQuestionExtension = questionExtensionFor(true);
|