@fungi.computer/shiitake-extensions 0.2.0 → 0.4.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 +33 -21
- package/dist/goal/contract.d.ts +27 -8
- package/dist/goal/contract.js +20 -3
- package/dist/goal/host.d.ts +4 -1
- package/dist/goal/host.js +375 -128
- package/dist/goal/prompts.d.ts +73 -0
- package/dist/goal/prompts.js +98 -0
- package/dist/home/host.js +9 -3
- package/dist/internal/headline.d.ts +7 -0
- package/dist/internal/headline.js +18 -0
- package/dist/loop/client.js +1 -1
- package/dist/loop/contract.d.ts +6 -2
- package/dist/loop/contract.js +10 -1
- package/dist/loop/host.js +10 -12
- package/dist/loop/prompts.d.ts +56 -0
- package/dist/loop/prompts.js +54 -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 -304
- package/dist/roster/conversation-agents.d.ts +22 -15
- package/dist/roster/conversation-agents.js +161 -37
- package/dist/roster.d.ts +8 -2
- package/dist/roster.js +33 -6
- package/dist/skills/host.js +20 -2
- 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 +5 -5
- package/dist/subagents/host.js +152 -22
- package/docs/api/goal/contract.md +5 -2
- package/docs/api/goal/host.md +22 -1
- package/docs/api/home/host.md +6 -5
- package/docs/api/loop/contract.md +5 -2
- 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 +15 -7
- package/docs/api/roster.md +39 -2
- package/docs/api/subagents/contract.md +101 -28
- package/docs/api/subagents/host.md +5 -2
- package/package.json +5 -6
package/dist/question/host.js
CHANGED
|
@@ -1,24 +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 {
|
|
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";
|
|
13
13
|
const config = {
|
|
14
14
|
maximumOpen: 4,
|
|
15
15
|
liveWindowMs: 600_000,
|
|
16
|
+
// No Agent holds a routed ask forever: past this a person decides.
|
|
17
|
+
routedEscalationMs: 900_000,
|
|
18
|
+
routesToSender: false,
|
|
16
19
|
idle: "Need a person's decision? forage.describe({path:'question'}) first.",
|
|
17
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.",
|
|
18
|
-
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.",
|
|
19
22
|
};
|
|
20
23
|
const Response = Schema.Union([
|
|
21
24
|
Schema.Struct({ status: Schema.Literal("answered"), answers: Answers }),
|
|
25
|
+
Schema.Struct({
|
|
26
|
+
status: Schema.Literal("answered"),
|
|
27
|
+
answers: Answers,
|
|
28
|
+
answeredBy: AnsweredBy,
|
|
29
|
+
}),
|
|
22
30
|
Schema.Struct({
|
|
23
31
|
status: Schema.Literal("declined"),
|
|
24
32
|
note: DeclineInput.fields.note,
|
|
@@ -35,6 +43,7 @@ const Response = Schema.Union([
|
|
|
35
43
|
]);
|
|
36
44
|
const AskRow = Schema.Struct({
|
|
37
45
|
askId: OpenAsk.fields.askId,
|
|
46
|
+
waitingOn: Schema.optional(AttentionRecipientSchema),
|
|
38
47
|
questions: Questions,
|
|
39
48
|
status: OpenAsk.fields.status,
|
|
40
49
|
createdAt: OpenAsk.fields.createdAt,
|
|
@@ -81,7 +90,7 @@ const Receipt = Schema.Struct({
|
|
|
81
90
|
sessionId: Schema.String,
|
|
82
91
|
requestId: Schema.String,
|
|
83
92
|
payload: Schema.String,
|
|
84
|
-
result:
|
|
93
|
+
result: AgentAnswerResult,
|
|
85
94
|
});
|
|
86
95
|
const collections = {
|
|
87
96
|
deliveries: defineCollection(Schema.Struct({
|
|
@@ -163,6 +172,8 @@ function closeAsk(ctx, row, outcome, followUpWorkId = null, toolCallId) {
|
|
|
163
172
|
yield* ctx.state.asks.remove(row.askId);
|
|
164
173
|
if (row.expiry !== null)
|
|
165
174
|
yield* ctx.timers.cancel(`ask:${row.askId}`);
|
|
175
|
+
if (row.waitingOn !== undefined)
|
|
176
|
+
yield* ctx.timers.cancel(routeTimer(row.askId));
|
|
166
177
|
yield* ctx.attention.clear(row.sessionId, row.askId);
|
|
167
178
|
yield* ctx.observe(row.sessionId);
|
|
168
179
|
});
|
|
@@ -279,13 +290,16 @@ function settle(ctx, run) {
|
|
|
279
290
|
});
|
|
280
291
|
}
|
|
281
292
|
function project(row) {
|
|
282
|
-
|
|
293
|
+
const open = {
|
|
283
294
|
askId: row.askId,
|
|
284
295
|
questions: row.questions,
|
|
285
296
|
status: row.status,
|
|
286
297
|
createdAt: row.createdAt,
|
|
287
298
|
expiresAt: row.expiry?.at ?? null,
|
|
288
299
|
};
|
|
300
|
+
return row.waitingOn === undefined
|
|
301
|
+
? open
|
|
302
|
+
: { ...open, waitingOn: row.waitingOn };
|
|
289
303
|
}
|
|
290
304
|
const encoder = new TextEncoder();
|
|
291
305
|
/** Keep every question and selection represented within the follow-up byte budget. */
|
|
@@ -301,7 +315,9 @@ function clip(text, budget) {
|
|
|
301
315
|
}
|
|
302
316
|
function responsePrompt(row, response) {
|
|
303
317
|
const prefix = response.status === "answered"
|
|
304
|
-
?
|
|
318
|
+
? "answeredBy" in response
|
|
319
|
+
? `Answer from Agent ${response.answeredBy.agentId} to ask ${row.askId}:\n`
|
|
320
|
+
: `Answer to ask ${row.askId}:\n`
|
|
305
321
|
: response.status === "declined"
|
|
306
322
|
? `Declined ask ${row.askId}:\n${response.note === undefined ? "" : `Note: ${clip(response.note, 2_048)}\n`}`
|
|
307
323
|
: `Ask ${row.askId} expired; ${response.action === "stop" ? "do not proceed with this decision" : "recommended default"}:\n`;
|
|
@@ -342,6 +358,8 @@ function deliverResponse(ctx, row, response) {
|
|
|
342
358
|
requireExistingSession: true,
|
|
343
359
|
when: "now",
|
|
344
360
|
correlation: row.askId,
|
|
361
|
+
// The continued work keeps its sender, so a second question still waits on that Agent.
|
|
362
|
+
continuesWorkId: row.workId,
|
|
345
363
|
prompt: {
|
|
346
364
|
text: responsePrompt(row, response),
|
|
347
365
|
requestId: `ask:${row.askId}`,
|
|
@@ -356,7 +374,11 @@ function deliverResponse(ctx, row, response) {
|
|
|
356
374
|
: { status: "undeliverable", reason: delivered.reason };
|
|
357
375
|
yield* closeAsk(ctx, row, outcome, delivered.status === "sent" ? delivered.workId : null);
|
|
358
376
|
return delivered.status === "sent"
|
|
359
|
-
? {
|
|
377
|
+
? {
|
|
378
|
+
result: "accepted",
|
|
379
|
+
via: "follow_up",
|
|
380
|
+
commandId: delivered.workId,
|
|
381
|
+
}
|
|
360
382
|
: { result: "closed", status: "undeliverable" };
|
|
361
383
|
});
|
|
362
384
|
}
|
|
@@ -376,6 +398,13 @@ function expireAsk(ctx, row, now) {
|
|
|
376
398
|
return Effect.gen(function* () {
|
|
377
399
|
if (row.expiry === null || now < row.expiry.at || row.pending !== null)
|
|
378
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
|
+
}
|
|
379
408
|
const response = row.expiry.action === "stop"
|
|
380
409
|
? { status: "expired", action: "stop" }
|
|
381
410
|
: {
|
|
@@ -408,15 +437,21 @@ function validAnswers(questions, values) {
|
|
|
408
437
|
return valid;
|
|
409
438
|
}
|
|
410
439
|
function responseIdentity(input) {
|
|
411
|
-
const response = input.kind === "answer"
|
|
412
|
-
?
|
|
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 }
|
|
413
448
|
: input.note === undefined
|
|
414
449
|
? { status: "declined" }
|
|
415
450
|
: { status: "declined", note: input.note };
|
|
416
451
|
const payload = JSON.stringify([
|
|
417
452
|
input.kind,
|
|
418
453
|
input.askId,
|
|
419
|
-
input.kind === "answer"
|
|
454
|
+
input.kind === "answer" || input.kind === "agent_answer"
|
|
420
455
|
? Object.entries(input.answers)
|
|
421
456
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
422
457
|
.map(([id, answer]) => [
|
|
@@ -425,6 +460,9 @@ function responseIdentity(input) {
|
|
|
425
460
|
answer.text ?? null,
|
|
426
461
|
])
|
|
427
462
|
: (input.note ?? null),
|
|
463
|
+
input.kind === "agent_answer"
|
|
464
|
+
? [input.caller.agentId, input.caller.sessionId]
|
|
465
|
+
: null,
|
|
428
466
|
]);
|
|
429
467
|
return { response, payload };
|
|
430
468
|
}
|
|
@@ -466,7 +504,12 @@ function respond(ctx, sessionId, input) {
|
|
|
466
504
|
const row = found.value;
|
|
467
505
|
if (row.pending !== null)
|
|
468
506
|
return yield* record({ result: "closed", status: row.pending.status });
|
|
469
|
-
|
|
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") {
|
|
470
513
|
const valid = validAnswers(row.questions, input.answers);
|
|
471
514
|
if (!valid)
|
|
472
515
|
return yield* Effect.fail(new ExtensionCommandError({
|
|
@@ -477,6 +520,80 @@ function respond(ctx, sessionId, input) {
|
|
|
477
520
|
return yield* record(yield* offerResponse(ctx, row, response));
|
|
478
521
|
});
|
|
479
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
|
+
}
|
|
480
597
|
// Mycelium's maintained operation interface uses Zod. The owner additionally
|
|
481
598
|
// decodes Questions for shared domain refinements and durable state.
|
|
482
599
|
const baseInput = z.strictObject({
|
|
@@ -591,6 +708,14 @@ const ToolResult = z.union([
|
|
|
591
708
|
status: z.literal("answered"),
|
|
592
709
|
answers: toolAnswers,
|
|
593
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
|
+
}),
|
|
594
719
|
z.strictObject({ status: z.literal("declined"), note: z.string() }),
|
|
595
720
|
z.strictObject({ status: z.literal("declined") }),
|
|
596
721
|
z.strictObject({ status: z.literal("expired"), action: z.literal("stop") }),
|
|
@@ -640,79 +765,16 @@ function answerNotice(result) {
|
|
|
640
765
|
}
|
|
641
766
|
function chooseQuestion(ctx, sessionId, askId) {
|
|
642
767
|
return Effect.gen(function* () {
|
|
643
|
-
if (askId
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
type: "choice",
|
|
653
|
-
message: "Which question would you like to answer?",
|
|
654
|
-
options: rows.map((row) => ({
|
|
655
|
-
value: row.askId,
|
|
656
|
-
label: row.questions
|
|
657
|
-
.map((question) => question.header)
|
|
658
|
-
.join(" · "),
|
|
659
|
-
})),
|
|
660
|
-
}, {}, "askId");
|
|
661
|
-
const first = rows[0];
|
|
662
|
-
if (first === undefined)
|
|
663
|
-
throw new Error("Expected the waiting question");
|
|
664
|
-
return { type: "selected", askId: first.askId };
|
|
665
|
-
}
|
|
666
|
-
return { type: "selected", askId };
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
function multipleSelection(question, selection) {
|
|
670
|
-
const parsed = Papa.parse(selection, {
|
|
671
|
-
delimiter: ",",
|
|
672
|
-
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 };
|
|
673
777
|
});
|
|
674
|
-
const selected = parsed.data[0] ?? [];
|
|
675
|
-
const offered = new Set(question.options.map((option) => option.label));
|
|
676
|
-
if (question.multiple !== true ||
|
|
677
|
-
parsed.errors.length > 0 ||
|
|
678
|
-
parsed.data.length > 1 ||
|
|
679
|
-
new Set(selected).size !== selected.length ||
|
|
680
|
-
selected.some((label) => !offered.has(label)))
|
|
681
|
-
return Effect.fail(new ExtensionCommandError({
|
|
682
|
-
reason: "invalid",
|
|
683
|
-
message: "Enter offered labels once each as a comma list; quote labels containing commas",
|
|
684
|
-
}));
|
|
685
|
-
return Effect.succeed(selected);
|
|
686
|
-
}
|
|
687
|
-
function singleSelection(question, selection) {
|
|
688
|
-
if (selection === "free_text")
|
|
689
|
-
return Effect.succeed([]);
|
|
690
|
-
const option = question.options.find((option) => `option:${option.label}` === selection);
|
|
691
|
-
return option === undefined
|
|
692
|
-
? Effect.fail(new ExtensionCommandError({
|
|
693
|
-
reason: "invalid",
|
|
694
|
-
message: "Choose an offered option or free text",
|
|
695
|
-
}))
|
|
696
|
-
: Effect.succeed([option.label]);
|
|
697
|
-
}
|
|
698
|
-
function promptQuestion(nextQuestion, next) {
|
|
699
|
-
if (nextQuestion.multiple === true)
|
|
700
|
-
return ask({
|
|
701
|
-
type: "text",
|
|
702
|
-
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.`,
|
|
703
|
-
}, { stage: "multiple", ...next }, "selection");
|
|
704
|
-
return ask({
|
|
705
|
-
type: "choice",
|
|
706
|
-
message: nextQuestion.question,
|
|
707
|
-
options: [
|
|
708
|
-
...nextQuestion.options.map((option) => ({
|
|
709
|
-
value: `option:${option.label}`,
|
|
710
|
-
label: option.label,
|
|
711
|
-
description: option.description,
|
|
712
|
-
})),
|
|
713
|
-
{ value: "free_text", label: "Write an answer" },
|
|
714
|
-
],
|
|
715
|
-
}, { stage: "selection", ...next }, "selection");
|
|
716
778
|
}
|
|
717
779
|
function answerFlow(ctx, sessionId, input) {
|
|
718
780
|
return Effect.gen(function* () {
|
|
@@ -734,73 +796,20 @@ function answerFlow(ctx, sessionId, input) {
|
|
|
734
796
|
/** Progress the selected Ask's draft; request replay still belongs to respond. */
|
|
735
797
|
function answerQuestion(ctx, sessionId, row, input) {
|
|
736
798
|
return Effect.gen(function* () {
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
:
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
message: "Unknown question step",
|
|
752
|
-
}));
|
|
753
|
-
let answer;
|
|
754
|
-
if (draft.stage === "text")
|
|
755
|
-
answer =
|
|
756
|
-
draft.text.length === 0
|
|
757
|
-
? { selected: draft.selected }
|
|
758
|
-
: { selected: draft.selected, text: draft.text };
|
|
759
|
-
else if (draft.stage === "selection" || draft.stage === "multiple") {
|
|
760
|
-
const selected = yield* draft.stage === "multiple"
|
|
761
|
-
? multipleSelection(question, draft.selection)
|
|
762
|
-
: singleSelection(question, draft.selection);
|
|
763
|
-
// A single choice is complete; only multiple selection and free text
|
|
764
|
-
// need an explicit submit.
|
|
765
|
-
if (draft.stage === "multiple" || selected.length === 0)
|
|
766
|
-
return ask({
|
|
767
|
-
type: "text",
|
|
768
|
-
message: selected.length === 0
|
|
769
|
-
? `Your answer for ${question.header}:`
|
|
770
|
-
: `Additional context for ${question.header} (leave blank to skip):`,
|
|
771
|
-
}, {
|
|
772
|
-
stage: "text",
|
|
773
|
-
askId: row.askId,
|
|
774
|
-
requestId,
|
|
775
|
-
index,
|
|
776
|
-
answers,
|
|
777
|
-
selected,
|
|
778
|
-
}, "text");
|
|
779
|
-
answer = { selected };
|
|
780
|
-
}
|
|
781
|
-
if (answer !== undefined) {
|
|
782
|
-
answers = { ...answers, [question.id]: answer };
|
|
783
|
-
index += 1;
|
|
784
|
-
if (index === row.questions.length) {
|
|
785
|
-
const result = yield* respond(ctx, sessionId, {
|
|
786
|
-
kind: "answer",
|
|
787
|
-
askId: row.askId,
|
|
788
|
-
requestId,
|
|
789
|
-
answers,
|
|
790
|
-
});
|
|
791
|
-
return answerNotice(result);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
if (row.status === "settled" || row.pending !== null)
|
|
795
|
-
return done({
|
|
796
|
-
type: "info",
|
|
797
|
-
message: "That question is already closed.",
|
|
798
|
-
});
|
|
799
|
-
const next = { askId: row.askId, requestId, index, answers };
|
|
800
|
-
const nextQuestion = row.questions[index];
|
|
801
|
-
if (nextQuestion === undefined)
|
|
802
|
-
throw new Error("Expected the next question");
|
|
803
|
-
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);
|
|
804
813
|
});
|
|
805
814
|
}
|
|
806
815
|
function replayQuestion(ctx, open, workId, digest, toolCallId) {
|
|
@@ -849,163 +858,198 @@ function answerCatalog(available) {
|
|
|
849
858
|
status: "unavailable",
|
|
850
859
|
reason: "There is no waiting question to answer.",
|
|
851
860
|
},
|
|
852
|
-
projections: {
|
|
853
|
-
slash: { aliases: ["answer"], order: 5 },
|
|
854
|
-
palette: { order: 5 },
|
|
855
|
-
help: { order: 5 },
|
|
856
|
-
},
|
|
857
861
|
},
|
|
858
862
|
]);
|
|
859
863
|
}
|
|
860
864
|
/** One durable question owner over the ordinary extension doors. */
|
|
861
|
-
export
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
.
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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);
|
|
918
934
|
}),
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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,
|
|
960
1023
|
};
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
createdAt: now,
|
|
971
|
-
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
|
|
972
1033
|
? {
|
|
973
|
-
|
|
974
|
-
|
|
1034
|
+
status: "waiting",
|
|
1035
|
+
askId: row.askId,
|
|
1036
|
+
until: now + window,
|
|
975
1037
|
}
|
|
976
|
-
:
|
|
977
|
-
};
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
? {
|
|
995
|
-
status: "waiting",
|
|
996
|
-
askId: row.askId,
|
|
997
|
-
until: now + window,
|
|
998
|
-
}
|
|
999
|
-
: { status: "parked", askId: row.askId };
|
|
1000
|
-
})));
|
|
1001
|
-
const result = created.status === "waiting"
|
|
1002
|
-
? await waitForResponse(ctx, created.askId, created.until, invocation)
|
|
1003
|
-
: created;
|
|
1004
|
-
// Zod owns the JSON output representation, including mutable arrays.
|
|
1005
|
-
return ToolResult.parse(result);
|
|
1006
|
-
},
|
|
1007
|
-
}),
|
|
1008
|
-
},
|
|
1009
|
-
});
|
|
1010
|
-
},
|
|
1011
|
-
});
|
|
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);
|