@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.
Files changed (39) hide show
  1. package/README.md +20 -16
  2. package/dist/goal/contract.d.ts +6 -0
  3. package/dist/goal/contract.js +6 -0
  4. package/dist/goal/host.js +183 -31
  5. package/dist/loop/contract.d.ts +136 -2
  6. package/dist/loop/contract.js +45 -2
  7. package/dist/loop/host.js +23 -0
  8. package/dist/question/answer-flow.d.ts +27 -0
  9. package/dist/question/answer-flow.js +115 -0
  10. package/dist/question/client.d.ts +1 -1
  11. package/dist/question/client.js +22 -3
  12. package/dist/question/contract.d.ts +202 -60
  13. package/dist/question/contract.js +78 -22
  14. package/dist/question/host.d.ts +4 -0
  15. package/dist/question/host.js +348 -339
  16. package/dist/roster/conversation-agents.d.ts +21 -1
  17. package/dist/roster/conversation-agents.js +47 -2
  18. package/dist/roster.d.ts +5 -2
  19. package/dist/roster.js +16 -5
  20. package/dist/schedules.d.ts +178 -0
  21. package/dist/schedules.js +182 -0
  22. package/dist/subagents/client.js +64 -20
  23. package/dist/subagents/contract.d.ts +123 -10
  24. package/dist/subagents/contract.js +38 -5
  25. package/dist/subagents/host.d.ts +2 -4
  26. package/dist/subagents/host.js +152 -22
  27. package/docs/api/README.md +1 -0
  28. package/docs/api/goal/contract.md +1 -1
  29. package/docs/api/goal/host.md +22 -1
  30. package/docs/api/loop/contract.md +43 -31
  31. package/docs/api/question/client.md +2 -1
  32. package/docs/api/question/contract.md +183 -51
  33. package/docs/api/question/host.md +30 -0
  34. package/docs/api/roster/conversation-agents.md +34 -8
  35. package/docs/api/roster.md +39 -2
  36. package/docs/api/schedules.md +481 -0
  37. package/docs/api/subagents/contract.md +101 -28
  38. package/docs/api/subagents/host.md +5 -3
  39. package/package.json +9 -6
@@ -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 Papa from "papaparse";
11
- import { titleLimit } from "../internal/headline.js";
12
- import { ask, catalog, done, freshRequestId } from "../internal/catalog.js";
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: HumanResult,
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
- return {
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
- ? `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`
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
- ? { result: "accepted", via: "follow_up" }
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
- ? { 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 }
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
- 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") {
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 === undefined) {
645
- const rows = (yield* ctx.state.asks.page({
646
- index: sessionId,
647
- limit: ctx.config.maximumOpen,
648
- })).filter((row) => row.pending === null);
649
- if (rows.length === 0)
650
- return done({ type: "info", message: "No questions are waiting." });
651
- if (rows.length > 1)
652
- return ask({
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 draft = "stage" in input
773
- ? input
774
- : {
775
- stage: "start",
776
- index: 0,
777
- requestId: yield* freshRequestId,
778
- answers: {},
779
- };
780
- let { index, answers } = draft;
781
- const { requestId } = draft;
782
- const question = row.questions[index];
783
- if (question === undefined)
784
- return yield* Effect.fail(new ExtensionCommandError({
785
- reason: "invalid",
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 const questionExtension = defineExtension({
897
- id: questionContract.id,
898
- version: "1.0.0",
899
- })
900
- .config(config)
901
- .state({ version: 1, migrations: {}, collections })
902
- .define({
903
- catalog: answerCatalog(false),
904
- attention: true,
905
- timers: true,
906
- hooks: {
907
- onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run), { discard: true }),
908
- onTimer: (ctx, timer) => Effect.gen(function* () {
909
- if (!timer.key.startsWith("ask:"))
910
- return;
911
- const row = yield* ctx.state.asks.get(timer.key.slice(4));
912
- if (Option.isSome(row))
913
- yield* expireAsk(ctx, row.value, timer.firedAt);
914
- }),
915
- },
916
- prompt: (ctx) => [{ name: "question", text: ctx.config.idle }],
917
- projection: {
918
- schema: questionContract.projection.schema,
919
- catalog: (value) => answerCatalog(value.asks.length > 0),
920
- read: (ctx, sessionId) => Option.some({
921
- asks: ctx.state.asks
922
- .peekPage({ index: sessionId, limit: ctx.config.maximumOpen })
923
- .filter((row) => row.pending === null)
924
- .map(project),
925
- }),
926
- },
927
- commands: {
928
- specs: questionContract.commands,
929
- handlers: (ctx) => ({
930
- answerFlow: ({ sessionId, input }) => answerFlow(ctx, sessionId, input),
931
- answer: ({ sessionId, input }) => respond(ctx, sessionId, { kind: "answer", ...input }),
932
- decline: ({ sessionId, input }) => respond(ctx, sessionId, { kind: "decline", ...input }),
933
- list: ({ sessionId }) => ctx.state.asks
934
- .page({ index: sessionId, limit: ctx.config.maximumOpen })
935
- .pipe(Effect.map((rows) => ({
936
- asks: rows.filter((row) => row.pending === null).map(project),
937
- }))),
938
- }),
939
- },
940
- tools: (ctx, { sessionId, workId }) => {
941
- const runAttemptId = nanoid();
942
- return Mycelium.module({
943
- id: "botanical.session-question",
944
- name: "question",
945
- description: ctx.config.guide,
946
- operations: {
947
- withdraw: Mycelium.operation({
948
- replay: "unsafe",
949
- description: "Withdraw a question that no longer needs a human answer. Repeating withdrawal preserves its closed outcome.",
950
- input: z.strictObject({ askId: z.string().min(1).max(256) }),
951
- output: WithdrawResult,
952
- 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);
953
934
  }),
954
- ask: Mycelium.operation({
955
- replay: "unsafe",
956
- description: ctx.config.askDescription,
957
- input,
958
- output: ToolResult,
959
- execute: async (value, invocation) => {
960
- const created = await Effect.runPromise(ctx.transact(Effect.gen(function* () {
961
- const questions = yield* Schema.decodeUnknownEffect(Questions)(value.questions).pipe(Effect.mapError(() => new ExtensionCommandError({
962
- reason: "invalid",
963
- message: "Invalid question identifiers or option labels",
964
- })));
965
- const digest = questionDigest(sessionId, workId, questions);
966
- const rows = yield* ctx.state.asks.page({
967
- index: sessionId,
968
- limit: ctx.config.maximumOpen,
969
- });
970
- const open = yield* Effect.forEach(rows, (row) => row.workId === workId &&
971
- row.status === "live" &&
972
- row.runAttemptId !== runAttemptId
973
- ? parkAsk(ctx, row)
974
- : Effect.succeed(row));
975
- const replay = yield* replayQuestion(ctx, open, workId, digest, invocation.toolCallId);
976
- if (replay !== undefined)
977
- return replay;
978
- if (open.length === ctx.config.maximumOpen)
979
- return {
980
- status: "refused",
981
- reason: "session_limit",
982
- askIds: open.map((row) => row.askId),
983
- };
984
- const now = yield* Clock.currentTimeMillis;
985
- const window = invocation.deadlineAt === null
986
- ? 0
987
- : Math.max(0, Math.min(ctx.config.liveWindowMs, invocation.deadlineAt - now - deadlineReserveMs));
988
- const live = value.mode === "wait" && window > 0;
989
- const existingLive = open.filter((row) => row.workId === workId && row.status === "live");
990
- if (live && existingLive.length > 0)
991
- return {
992
- status: "refused",
993
- reason: "live_limit",
994
- 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,
995
1023
  };
996
- const row = {
997
- askId: nanoid(),
998
- sessionId,
999
- workId,
1000
- digest,
1001
- questions,
1002
- status: live ? "live" : "parked",
1003
- runAttemptId: live ? runAttemptId : null,
1004
- pending: null,
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
- at: now + value.onExpire.afterMinutes * 60_000,
1009
- action: value.onExpire.action,
1034
+ status: "waiting",
1035
+ askId: row.askId,
1036
+ until: now + window,
1010
1037
  }
1011
- : null,
1012
- };
1013
- yield* ctx.state.asks.put(row);
1014
- if (row.expiry !== null)
1015
- yield* ctx.timers.set(`ask:${row.askId}`, row.expiry.at);
1016
- const attention = {
1017
- id: row.askId,
1018
- kind: "question",
1019
- summary: questions
1020
- .map((question) => question.header)
1021
- .join(", "),
1022
- since: now,
1023
- };
1024
- yield* ctx.attention.set(sessionId, row.expiry === null
1025
- ? attention
1026
- : { ...attention, expiresAt: row.expiry.at });
1027
- yield* ctx.observe(sessionId);
1028
- return live
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);