@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.
Files changed (46) hide show
  1. package/README.md +33 -21
  2. package/dist/goal/contract.d.ts +27 -8
  3. package/dist/goal/contract.js +20 -3
  4. package/dist/goal/host.d.ts +4 -1
  5. package/dist/goal/host.js +375 -128
  6. package/dist/goal/prompts.d.ts +73 -0
  7. package/dist/goal/prompts.js +98 -0
  8. package/dist/home/host.js +9 -3
  9. package/dist/internal/headline.d.ts +7 -0
  10. package/dist/internal/headline.js +18 -0
  11. package/dist/loop/client.js +1 -1
  12. package/dist/loop/contract.d.ts +6 -2
  13. package/dist/loop/contract.js +10 -1
  14. package/dist/loop/host.js +10 -12
  15. package/dist/loop/prompts.d.ts +56 -0
  16. package/dist/loop/prompts.js +54 -0
  17. package/dist/question/answer-flow.d.ts +27 -0
  18. package/dist/question/answer-flow.js +115 -0
  19. package/dist/question/client.d.ts +1 -1
  20. package/dist/question/client.js +22 -3
  21. package/dist/question/contract.d.ts +202 -60
  22. package/dist/question/contract.js +78 -22
  23. package/dist/question/host.d.ts +4 -0
  24. package/dist/question/host.js +348 -304
  25. package/dist/roster/conversation-agents.d.ts +22 -15
  26. package/dist/roster/conversation-agents.js +161 -37
  27. package/dist/roster.d.ts +8 -2
  28. package/dist/roster.js +33 -6
  29. package/dist/skills/host.js +20 -2
  30. package/dist/subagents/client.js +64 -20
  31. package/dist/subagents/contract.d.ts +123 -10
  32. package/dist/subagents/contract.js +38 -5
  33. package/dist/subagents/host.d.ts +5 -5
  34. package/dist/subagents/host.js +152 -22
  35. package/docs/api/goal/contract.md +5 -2
  36. package/docs/api/goal/host.md +22 -1
  37. package/docs/api/home/host.md +6 -5
  38. package/docs/api/loop/contract.md +5 -2
  39. package/docs/api/question/client.md +2 -1
  40. package/docs/api/question/contract.md +183 -51
  41. package/docs/api/question/host.md +30 -0
  42. package/docs/api/roster/conversation-agents.md +15 -7
  43. package/docs/api/roster.md +39 -2
  44. package/docs/api/subagents/contract.md +101 -28
  45. package/docs/api/subagents/host.md +5 -2
  46. package/package.json +5 -6
@@ -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 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";
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: HumanResult,
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
- return {
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
- ? `Answer to ask ${row.askId}:\n`
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
- ? { result: "accepted", via: "follow_up" }
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
- ? { status: "answered", answers: input.answers }
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
- if (input.kind === "answer") {
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 === undefined) {
644
- const rows = (yield* ctx.state.asks.page({
645
- index: sessionId,
646
- limit: ctx.config.maximumOpen,
647
- })).filter((row) => row.pending === null);
648
- if (rows.length === 0)
649
- return done({ type: "info", message: "No questions are waiting." });
650
- if (rows.length > 1)
651
- return ask({
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 draft = "stage" in input
738
- ? input
739
- : {
740
- stage: "start",
741
- index: 0,
742
- requestId: yield* freshRequestId,
743
- answers: {},
744
- };
745
- let { index, answers } = draft;
746
- const { requestId } = draft;
747
- const question = row.questions[index];
748
- if (question === undefined)
749
- return yield* Effect.fail(new ExtensionCommandError({
750
- reason: "invalid",
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 const questionExtension = defineExtension({
862
- id: questionContract.id,
863
- version: "1.0.0",
864
- })
865
- .config(config)
866
- .state({ version: 1, migrations: {}, collections })
867
- .define({
868
- catalog: answerCatalog(false),
869
- attention: true,
870
- timers: true,
871
- hooks: {
872
- onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run), { discard: true }),
873
- onTimer: (ctx, timer) => Effect.gen(function* () {
874
- if (!timer.key.startsWith("ask:"))
875
- return;
876
- const row = yield* ctx.state.asks.get(timer.key.slice(4));
877
- if (Option.isSome(row))
878
- yield* expireAsk(ctx, row.value, timer.firedAt);
879
- }),
880
- },
881
- prompt: (ctx) => [{ name: "question", text: ctx.config.idle }],
882
- projection: {
883
- schema: questionContract.projection.schema,
884
- catalog: (value) => answerCatalog(value.asks.length > 0),
885
- read: (ctx, sessionId) => Option.some({
886
- asks: ctx.state.asks
887
- .peekPage({ index: sessionId, limit: ctx.config.maximumOpen })
888
- .filter((row) => row.pending === null)
889
- .map(project),
890
- }),
891
- },
892
- commands: {
893
- specs: questionContract.commands,
894
- handlers: (ctx) => ({
895
- answerFlow: ({ sessionId, input }) => answerFlow(ctx, sessionId, input),
896
- answer: ({ sessionId, input }) => respond(ctx, sessionId, { kind: "answer", ...input }),
897
- decline: ({ sessionId, input }) => respond(ctx, sessionId, { kind: "decline", ...input }),
898
- list: ({ sessionId }) => ctx.state.asks
899
- .page({ index: sessionId, limit: ctx.config.maximumOpen })
900
- .pipe(Effect.map((rows) => ({
901
- asks: rows.filter((row) => row.pending === null).map(project),
902
- }))),
903
- }),
904
- },
905
- tools: (ctx, { sessionId, workId }) => {
906
- const runAttemptId = nanoid();
907
- return Mycelium.module({
908
- id: "botanical.session-question",
909
- name: "question",
910
- description: ctx.config.guide,
911
- operations: {
912
- withdraw: Mycelium.operation({
913
- replay: "unsafe",
914
- description: "Withdraw a question that no longer needs a human answer. Repeating withdrawal preserves its closed outcome.",
915
- input: z.strictObject({ askId: z.string().min(1).max(256) }),
916
- output: WithdrawResult,
917
- execute: async ({ askId }) => WithdrawResult.parse(await Effect.runPromise(ctx.transact(withdraw(ctx, sessionId, askId)))),
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
- ask: Mycelium.operation({
920
- replay: "unsafe",
921
- description: ctx.config.askDescription,
922
- input,
923
- output: ToolResult,
924
- execute: async (value, invocation) => {
925
- const created = await Effect.runPromise(ctx.transact(Effect.gen(function* () {
926
- const questions = yield* Schema.decodeUnknownEffect(Questions)(value.questions).pipe(Effect.mapError(() => new ExtensionCommandError({
927
- reason: "invalid",
928
- message: "Invalid question identifiers or option labels",
929
- })));
930
- const digest = questionDigest(sessionId, workId, questions);
931
- const rows = yield* ctx.state.asks.page({
932
- index: sessionId,
933
- limit: ctx.config.maximumOpen,
934
- });
935
- const open = yield* Effect.forEach(rows, (row) => row.workId === workId &&
936
- row.status === "live" &&
937
- row.runAttemptId !== runAttemptId
938
- ? parkAsk(ctx, row)
939
- : Effect.succeed(row));
940
- const replay = yield* replayQuestion(ctx, open, workId, digest, invocation.toolCallId);
941
- if (replay !== undefined)
942
- return replay;
943
- if (open.length === ctx.config.maximumOpen)
944
- return {
945
- status: "refused",
946
- reason: "session_limit",
947
- askIds: open.map((row) => row.askId),
948
- };
949
- const now = yield* Clock.currentTimeMillis;
950
- const window = invocation.deadlineAt === null
951
- ? 0
952
- : Math.max(0, Math.min(ctx.config.liveWindowMs, invocation.deadlineAt - now - deadlineReserveMs));
953
- const live = value.mode === "wait" && window > 0;
954
- const existingLive = open.filter((row) => row.workId === workId && row.status === "live");
955
- if (live && existingLive.length > 0)
956
- return {
957
- status: "refused",
958
- reason: "live_limit",
959
- askIds: existingLive.map((row) => row.askId),
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
- const row = {
962
- askId: nanoid(),
963
- sessionId,
964
- workId,
965
- digest,
966
- questions,
967
- status: live ? "live" : "parked",
968
- runAttemptId: live ? runAttemptId : null,
969
- pending: null,
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
- at: now + value.onExpire.afterMinutes * 60_000,
974
- action: value.onExpire.action,
1034
+ status: "waiting",
1035
+ askId: row.askId,
1036
+ until: now + window,
975
1037
  }
976
- : null,
977
- };
978
- yield* ctx.state.asks.put(row);
979
- if (row.expiry !== null)
980
- yield* ctx.timers.set(`ask:${row.askId}`, row.expiry.at);
981
- const attention = {
982
- id: row.askId,
983
- kind: "question",
984
- summary: questions
985
- .map((question) => question.header)
986
- .join(", "),
987
- since: now,
988
- };
989
- yield* ctx.attention.set(sessionId, row.expiry === null
990
- ? attention
991
- : { ...attention, expiresAt: row.expiry.at });
992
- yield* ctx.observe(sessionId);
993
- return live
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);