@sigrun/ask-user 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-11-05
9
+ ### Added
10
+ - The `ask_user_question` argument contract: `AskParamsSchema` (1–4
11
+ questions, 2–4 options, header ≤ 16 chars, label ≤ 60 chars) plus
12
+ `prepareAskArguments` for models that stringify the array.
13
+ - `validateAsk`: unique option labels per question, unique headers, the
14
+ reserved labels `Other` / `Type something.`, and no preview on
15
+ multi-select questions.
16
+ - `buildCard`: the question card tree — a submit-mode form with one
17
+ section per question, each holding a required choice labelled with the
18
+ full question text, the ` (Recommended)` suffix mapping to
19
+ `recommended: true`, and a free-text "Type something" row on every
20
+ choice.
21
+ - The `ask_user_question` tool (model-only, `replay: "safe"`): validate,
22
+ refuse in scheduled job runs, register the waiter, seed the card
23
+ idempotently per call id, one snapshot read, then wait for the human.
24
+ Declines come back as results, not errors; a clean shutdown leaves the
25
+ card pending for the post-restart rerun; any other abort settles it
26
+ `cancelled` and rethrows the original abort. No timer ever answers.
27
+ - `Waiters`: the callId-keyed registry joining `interactionSettled`
28
+ events to in-flight waits.
29
+ - `formatResult`: the settled outcome folded into the tool output and
30
+ `details`.
31
+ - The extension factory: `interactionKinds: ["ask.questions"]`,
32
+ `interactiveTools: ["ask_user_question"]`, the `interactionSettled`
33
+ and `harnessShutdown` lifecycle handlers.
34
+ - The prompt section: the asking discipline, rendered only when the
35
+ run's agent carries `ask_user_question`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael MacLeod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # @sigrun/ask-user
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Structured questions from the agent for [sigrun](https://github.com/sigrun-ai/sigrun).
4
+ The agent asks through its `ask_user_question` tool; the question card renders
5
+ in the conversation and waits for the user's answers — it never auto-selects
6
+ an answer, and it waits indefinitely.
7
+
8
+ ## Install
9
+
10
+ Add the extension to `extensions[]` in the sigrun settings file and restart:
11
+
12
+ ```json
13
+ {
14
+ "extensions": ["@sigrun/ask-user"]
15
+ }
16
+ ```
17
+
18
+ (a git source — `git:host/path[@ref]` — works too; the repo's committed
19
+ `dist/` is what git-source installs load.)
20
+
21
+ Until `@sigrun/ask-user` is published, install from a local path pointing at
22
+ the BUILT entry — `"/path/to/sigrun-ask-user/dist/index.js"` in
23
+ `extensions[]`.
24
+
25
+ ## What you get
26
+
27
+ - **The `ask_user_question` tool**: one to four questions per call, each
28
+ with two to four options, a short header chip, and multi-select
29
+ where it fits. A label ending in ` (Recommended)` renders with a
30
+ recommended marker. The card always adds a free-text "Type something"
31
+ row, so `Other` and `Type something.` are reserved labels the model
32
+ must not offer. The numeric limits: 1–4 questions per call, 2–4
33
+ options per question, header ≤ 16 characters, label ≤ 60 characters.
34
+ - **The question card** in the conversation: one section per question,
35
+ each a required choice, submitted with "Submit answers" — or declined.
36
+ Questions wait for a human indefinitely; nothing answers on the user's
37
+ behalf.
38
+
39
+ ## Decline
40
+
41
+ The user can always decline a question instead of answering. A decline
42
+ does not error the agent — it is a result: the tool returns "declined"
43
+ and the agent continues from what the user says next (reply-to-decline
44
+ is an ordinary conversation message; the tool never second-guesses it).
45
+
46
+ ## Where it is hidden
47
+
48
+ - **Scheduled job runs**: there is nobody to ask. The tool refuses with
49
+ a tool error *before* seeding any card, telling the model to escalate
50
+ instead.
51
+ - **Curator runs and other non-interactive agents**: the extension
52
+ declares `ask_user_question` an interactive tool, so core withholds it
53
+ from agents a human never watches.
54
+
55
+ ## Known limits
56
+
57
+ - The card is declined or answered — there is no timer and no default.
58
+ - One to four questions per call; two to four options per question;
59
+ headers at most 16 characters; option labels at most 60.
60
+ - A question is bound to its tool call: on a process restart the pending
61
+ card is re-seeded from the same call id rather than duplicated.
@@ -0,0 +1,29 @@
1
+ # Third-party notices
2
+
3
+ ## @juicesharp/rpiv-ask-user-question
4
+
5
+ - <https://www.npmjs.com/package/@juicesharp/rpiv-ask-user-question>
6
+ - This extension's prompt guidance text is adapted from
7
+ `@juicesharp/rpiv-ask-user-question` 2.15.0.
8
+
9
+ MIT License
10
+
11
+ Copyright (c) juicesharp
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
package/dist/card.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The card shown to the user (spec §7.2): a root `form` bound to
3
+ * `/values` in submit mode, one `section` per question titled by its
4
+ * header, each holding exactly one required `choice` named `q1`…`q4`
5
+ * whose label is the full question text (the header is too short to
6
+ * carry it). Options are `o1`…`o4`; a ` (Recommended)` suffix maps to
7
+ * `recommended: true` with the suffix stripped. Every choice carries
8
+ * the free-text row (`allowOther: {label: "Type something"}`) — the
9
+ * reserved labels never appear in the model's options. There are no
10
+ * note fields: the TUI stepper only takes input in a step's first
11
+ * field, so a second field would be unreachable there.
12
+ */
13
+ import type { Tree } from "@sigrun/shared/blocks";
14
+ import type { AskParams } from "./schema.js";
15
+ /** The interaction kind the tool seeds with this card as its tree. */
16
+ export declare const ASK_KIND = "ask.questions";
17
+ export declare function buildCard(params: AskParams): Tree;
package/dist/card.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The card shown to the user (spec §7.2): a root `form` bound to
3
+ * `/values` in submit mode, one `section` per question titled by its
4
+ * header, each holding exactly one required `choice` named `q1`…`q4`
5
+ * whose label is the full question text (the header is too short to
6
+ * carry it). Options are `o1`…`o4`; a ` (Recommended)` suffix maps to
7
+ * `recommended: true` with the suffix stripped. Every choice carries
8
+ * the free-text row (`allowOther: {label: "Type something"}`) — the
9
+ * reserved labels never appear in the model's options. There are no
10
+ * note fields: the TUI stepper only takes input in a step's first
11
+ * field, so a second field would be unreachable there.
12
+ */
13
+ import { RECOMMENDED_SUFFIX } from "./validate.js";
14
+ /** The interaction kind the tool seeds with this card as its tree. */
15
+ export const ASK_KIND = "ask.questions";
16
+ function choiceOption(option, index) {
17
+ const recommended = option.label.endsWith(RECOMMENDED_SUFFIX);
18
+ const label = recommended ? option.label.slice(0, -RECOMMENDED_SUFFIX.length) : option.label;
19
+ return {
20
+ id: `o${index + 1}`,
21
+ label,
22
+ description: option.description,
23
+ ...(option.preview !== undefined ? { preview: option.preview } : {}),
24
+ ...(recommended ? { recommended: true } : {}),
25
+ };
26
+ }
27
+ function questionSection(question, index) {
28
+ const choice = {
29
+ type: "choice",
30
+ name: `q${index + 1}`,
31
+ label: question.question,
32
+ required: true,
33
+ multi: question.multiSelect === true,
34
+ allowOther: { label: "Type something" },
35
+ options: question.options.map(choiceOption),
36
+ };
37
+ return { type: "section", title: question.header, children: [choice] };
38
+ }
39
+ export function buildCard(params) {
40
+ return {
41
+ v: 1,
42
+ root: {
43
+ type: "form",
44
+ id: "ask",
45
+ bind: "/values",
46
+ mode: "submit",
47
+ submitLabel: "Submit answers",
48
+ children: params.questions.map(questionSection),
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The ask-user extension factory (Task 9): one {@link Waiters} registry
3
+ * shared by the tool's waits and the `interactionSettled` lifecycle
4
+ * handler, with `harnessShutdown` flipping the flag the tool's abort
5
+ * path reads to tell a clean restart from a real cancellation.
6
+ *
7
+ * The factory declares ONE parameter so the loader passes the host in
8
+ * (`factory.length > 0` gates the host argument); a defaulted first
9
+ * parameter would swallow it instead.
10
+ */
11
+ import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
12
+ /**
13
+ * The ask-user extension: the agent asks the user structured questions
14
+ * through `ask_user_question` and waits for the answer — no timer ever
15
+ * auto-selects an answer; the user answers or declines.
16
+ * The required host parameter keeps the factory at arity one for the loader.
17
+ */
18
+ export default function createAskUser(host: SigrunHost): SigrunExtensionResult;
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The ask-user extension factory (Task 9): one {@link Waiters} registry
3
+ * shared by the tool's waits and the `interactionSettled` lifecycle
4
+ * handler, with `harnessShutdown` flipping the flag the tool's abort
5
+ * path reads to tell a clean restart from a real cancellation.
6
+ *
7
+ * The factory declares ONE parameter so the loader passes the host in
8
+ * (`factory.length > 0` gates the host argument); a defaulted first
9
+ * parameter would swallow it instead.
10
+ */
11
+ import { defineExtension } from "@earendil-works/pi-durable";
12
+ import { ASK_KIND } from "./card.js";
13
+ import { askSection } from "./section.js";
14
+ import { createAskTool } from "./tool.js";
15
+ import { Waiters } from "./waiters.js";
16
+ /**
17
+ * The ask-user extension: the agent asks the user structured questions
18
+ * through `ask_user_question` and waits for the answer — no timer ever
19
+ * auto-selects an answer; the user answers or declines.
20
+ * The required host parameter keeps the factory at arity one for the loader.
21
+ */
22
+ export default function createAskUser(host) {
23
+ // ONE registry: the tool's pending waits and the lifecycle handler's
24
+ // settles must meet in the same map.
25
+ const waiters = new Waiters();
26
+ return {
27
+ extension: defineExtension({
28
+ name: "ask-user",
29
+ tools: [createAskTool({ host, waiters })],
30
+ sections: [askSection],
31
+ }),
32
+ interactionKinds: [ASK_KIND],
33
+ interactiveTools: ["ask_user_question"],
34
+ interactionSettled: (event) => waiters.settle(event),
35
+ harnessShutdown: () => {
36
+ waiters.shuttingDown = true;
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The tool result text (spec §7.5): what the model reads when the wait
3
+ * ends. An answer is one line per question, `<header>: <answer>`, where
4
+ * the answer is the selected option LABELS joined `", "` — the model
5
+ * knows its own labels; the option ids `o1`… would say nothing — with
6
+ * the free-text answer quoted and marked `(own words)` last. Declines
7
+ * and cancellations carry their own fixed sentences so the model never
8
+ * has to guess whether to retry or move on.
9
+ */
10
+ import type { AskParams } from "./schema.js";
11
+ import type { Settled } from "./waiters.js";
12
+ /** The structured payload the tool result carries as `details`. */
13
+ export type AskDetails = {
14
+ status: Settled["status"];
15
+ answers: {
16
+ question: string;
17
+ header: string;
18
+ selected: string[];
19
+ other?: string;
20
+ }[];
21
+ declinedVia?: "button" | "message";
22
+ };
23
+ /** The copy the model reads verbatim. */
24
+ export declare const DECLINED_BUTTON_TEXT = "The user declined to answer.";
25
+ export declare const DECLINED_MESSAGE_TEXT = "The user declined to answer and replied in chat instead; their message follows.";
26
+ export declare const CANCELLED_TEXT = "The question was cancelled before the user answered.";
27
+ export declare function formatResult(params: AskParams, settled: Settled): {
28
+ output: string;
29
+ details: AskDetails;
30
+ isError?: true;
31
+ };
package/dist/result.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The tool result text (spec §7.5): what the model reads when the wait
3
+ * ends. An answer is one line per question, `<header>: <answer>`, where
4
+ * the answer is the selected option LABELS joined `", "` — the model
5
+ * knows its own labels; the option ids `o1`… would say nothing — with
6
+ * the free-text answer quoted and marked `(own words)` last. Declines
7
+ * and cancellations carry their own fixed sentences so the model never
8
+ * has to guess whether to retry or move on.
9
+ */
10
+ /** The copy the model reads verbatim. */
11
+ export const DECLINED_BUTTON_TEXT = "The user declined to answer.";
12
+ export const DECLINED_MESSAGE_TEXT = "The user declined to answer and replied in chat instead; their message follows.";
13
+ export const CANCELLED_TEXT = "The question was cancelled before the user answered.";
14
+ /** One question's value under `q<n>`: an option id, or ids/other for multi. */
15
+ function answerPart(question, value) {
16
+ const labels = new Map(question.options.map((option, index) => [`o${index + 1}`, option.label]));
17
+ const one = (v, into) => {
18
+ if (typeof v === "string") {
19
+ const label = labels.get(v);
20
+ if (label !== undefined)
21
+ into.selected.push(label);
22
+ }
23
+ else if (typeof v === "object" && v !== null && !Array.isArray(v)) {
24
+ const other = v.other;
25
+ if (typeof other === "string")
26
+ into.other = other;
27
+ }
28
+ };
29
+ const part = { selected: [] };
30
+ if (Array.isArray(value)) {
31
+ for (const v of value)
32
+ one(v, part);
33
+ }
34
+ else {
35
+ one(value, part);
36
+ }
37
+ return part;
38
+ }
39
+ /** The answer sentence for one question: labels joined, own words last. */
40
+ function answerText(part) {
41
+ const pieces = [...part.selected];
42
+ if (part.other !== undefined)
43
+ pieces.push(`"${part.other}" (own words)`);
44
+ return pieces.join(", ");
45
+ }
46
+ export function formatResult(params, settled) {
47
+ if (settled.status === "skipped") {
48
+ // Decision: a skip without declinedVia reads as the button sentence.
49
+ // The field is only absent for declines older than the field, and the
50
+ // chat-reply copy would claim a message follows when none does.
51
+ return {
52
+ output: settled.declinedVia === "message" ? DECLINED_MESSAGE_TEXT : DECLINED_BUTTON_TEXT,
53
+ details: { status: "skipped", answers: [], ...(settled.declinedVia !== undefined ? { declinedVia: settled.declinedVia } : {}) },
54
+ };
55
+ }
56
+ if (settled.status !== "answered") {
57
+ return {
58
+ output: CANCELLED_TEXT,
59
+ details: { status: settled.status, answers: [] },
60
+ isError: true,
61
+ };
62
+ }
63
+ const answers = params.questions.map((question, index) => {
64
+ const part = answerPart(question, settled.values[`q${index + 1}`]);
65
+ return {
66
+ question: question.question,
67
+ header: question.header,
68
+ selected: part.selected,
69
+ ...(part.other !== undefined ? { other: part.other } : {}),
70
+ };
71
+ });
72
+ const output = answers.map((answer) => `${answer.header}: ${answerText(answer)}`).join("\n");
73
+ return { output, details: { status: "answered", answers } };
74
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The `ask_user_question` argument shape: the typebox schema the tool
3
+ * declares, and `prepareAskArguments` for models that stringify the
4
+ * array. The limits (1–4 questions, 2–4 options, header ≤ 16, label
5
+ * ≤ 60) are the ones the design keeps from rpiv so prompting habits
6
+ * carry over (spec §7.1); rules a schema cannot express live in
7
+ * `validate.ts`.
8
+ */
9
+ import { Type, type Static } from "@earendil-works/pi-ai";
10
+ export declare const AskOptionSchema: Type.TObject<{
11
+ label: Type.TString;
12
+ description: Type.TString;
13
+ preview: Type.TOptional<Type.TString>;
14
+ }>;
15
+ export declare const AskQuestionSchema: Type.TObject<{
16
+ question: Type.TString;
17
+ header: Type.TString;
18
+ multiSelect: Type.TOptional<Type.TBoolean>;
19
+ options: Type.TArray<Type.TObject<{
20
+ label: Type.TString;
21
+ description: Type.TString;
22
+ preview: Type.TOptional<Type.TString>;
23
+ }>>;
24
+ }>;
25
+ export declare const AskParamsSchema: Type.TObject<{
26
+ questions: Type.TArray<Type.TObject<{
27
+ question: Type.TString;
28
+ header: Type.TString;
29
+ multiSelect: Type.TOptional<Type.TBoolean>;
30
+ options: Type.TArray<Type.TObject<{
31
+ label: Type.TString;
32
+ description: Type.TString;
33
+ preview: Type.TOptional<Type.TString>;
34
+ }>>;
35
+ }>>;
36
+ }>;
37
+ export type AskOption = Static<typeof AskOptionSchema>;
38
+ export type AskQuestion = Static<typeof AskQuestionSchema>;
39
+ export type AskParams = Static<typeof AskParamsSchema>;
40
+ /**
41
+ * Models occasionally send the array as a JSON string; normalize that to
42
+ * parsed form. Anything else passes through unchanged (null/undefined throw
43
+ * here — the tool's own argument validation rejects them upstream as
44
+ * invalid_arguments). Pure.
45
+ */
46
+ export declare function prepareAskArguments(args: unknown): AskParams;
package/dist/schema.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The `ask_user_question` argument shape: the typebox schema the tool
3
+ * declares, and `prepareAskArguments` for models that stringify the
4
+ * array. The limits (1–4 questions, 2–4 options, header ≤ 16, label
5
+ * ≤ 60) are the ones the design keeps from rpiv so prompting habits
6
+ * carry over (spec §7.1); rules a schema cannot express live in
7
+ * `validate.ts`.
8
+ */
9
+ import { Type } from "@earendil-works/pi-ai";
10
+ export const AskOptionSchema = Type.Object({
11
+ label: Type.String({ maxLength: 60, description: "Short option label" }),
12
+ description: Type.String({ description: "What this option implies" }),
13
+ preview: Type.Optional(Type.String({ description: "Markdown preview (single-select questions only)" })),
14
+ });
15
+ export const AskQuestionSchema = Type.Object({
16
+ question: Type.String({ description: "The full question text" }),
17
+ header: Type.String({ maxLength: 16, description: "Short chip label, at most 16 characters" }),
18
+ multiSelect: Type.Optional(Type.Boolean({ description: "Allow choosing more than one option" })),
19
+ options: Type.Array(AskOptionSchema, { minItems: 2, maxItems: 4 }),
20
+ });
21
+ export const AskParamsSchema = Type.Object({
22
+ questions: Type.Array(AskQuestionSchema, { minItems: 1, maxItems: 4 }),
23
+ });
24
+ /**
25
+ * Models occasionally send the array as a JSON string; normalize that to
26
+ * parsed form. Anything else passes through unchanged (null/undefined throw
27
+ * here — the tool's own argument validation rejects them upstream as
28
+ * invalid_arguments). Pure.
29
+ */
30
+ export function prepareAskArguments(args) {
31
+ if (typeof args.questions === "string") {
32
+ const raw = args;
33
+ return { ...raw, questions: JSON.parse(raw.questions) };
34
+ }
35
+ return args;
36
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The static prompt section: the asking discipline, not the questions.
3
+ * Question content must never appear here — prompt bytes that change
4
+ * per call break every cache the provider keeps.
5
+ */
6
+ export declare const ASK_SECTION_TEXT = "Asking the user: when a decision is genuinely the user's and the answer would change the work, call `ask_user_question` instead of guessing. Don't ask what the conversation or the project already answers. Group related questions into one call (up to 4). Write each option so its consequence is clear, and put the option you recommend first with \" (Recommended)\" at the end of its label. Use `preview` to show concrete artifacts \u2014 a mockup, a code snippet, a config \u2014 when comparing them helps. The user can always answer in their own words or decline; if they decline, continue from what they say next.";
7
+ export declare const askSection: import("@earendil-works/pi-durable").PromptSection<import("@earendil-works/pi-durable").ToolRegistration>;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The static prompt section: the asking discipline, not the questions.
3
+ * Question content must never appear here — prompt bytes that change
4
+ * per call break every cache the provider keeps.
5
+ */
6
+ import { section } from "@earendil-works/pi-durable";
7
+ export const ASK_SECTION_TEXT = "Asking the user: when a decision is genuinely the user's and the answer would change the work, call `ask_user_question` instead of guessing. Don't ask what the conversation or the project already answers. Group related questions into one call (up to 4). Write each option so its consequence is clear, and put the option you recommend first with \" (Recommended)\" at the end of its label. Use `preview` to show concrete artifacts — a mockup, a code snippet, a config — when comparing them helps. The user can always answer in their own words or decline; if they decline, continue from what they say next.";
8
+ export const askSection = section("ask-user", (input) => input.agent.tools.some((tool) => tool.name === "ask_user_question") ? ASK_SECTION_TEXT : undefined);
package/dist/tool.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `ask_user_question` tool (spec §7.3–7.5): validate, seed the
3
+ * card, wait for a human — no timer anywhere, the user answers or
4
+ * declines, never a timeout.
5
+ *
6
+ * Order (load-bearing):
7
+ *
8
+ * 1. `validateAsk` — an uncardable call is a tool error with nothing
9
+ * seeded, so the model can fix the call instead of the user
10
+ * answering a broken card.
11
+ * 2. `jobs.isJobRun` — a scheduled job has nobody to ask; error BEFORE
12
+ * seeding so no orphan card lingers.
13
+ * 3. Register the waiter BEFORE seeding: an answer can only commit once
14
+ * the record exists, and the waiter is already listening by then.
15
+ * 4. `seedInteraction` is idempotent per callId — on the `replay:
16
+ * "safe"` rerun after a restart, the first-write-wins commit
17
+ * re-reads the same PENDING record instead of duplicating the card.
18
+ * 5. `snapshotInteraction` once after seeding: covers the answer that
19
+ * committed before the waiter attached, and the answer given while
20
+ * the process was down (the rerun path).
21
+ * 6. Await the waiter.
22
+ * 7. On abort: a settled waiter's answer or decline WON the race and
23
+ * stands; otherwise a clean shutdown (`waiters.shuttingDown`,
24
+ * `harnessShutdown` fires before closeHarness) leaves the record
25
+ * PENDING so the rerun re-seeds it, and any other abort settles the
26
+ * record `cancelled` — inside try/catch, because a closing session
27
+ * rejects the commit and the original abort must still propagate.
28
+ *
29
+ * The execute wrapper's finally discards the waiter entry: a throw
30
+ * BEFORE the await on `pending` (a rejecting seed) never runs
31
+ * `wait()`'s own finally, and without the discard the entry would
32
+ * leak past the failed call.
33
+ */
34
+ import { type ToolRegistration } from "@earendil-works/pi-durable";
35
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
36
+ import { AskParamsSchema } from "./schema.js";
37
+ import { Waiters } from "./waiters.js";
38
+ /** The tool description the model sees. */
39
+ export declare const ASK_TOOL_DESCRIPTION = "Ask the user 1-4 structured questions and wait for the answers. Each question has 2-4 options; the user can also answer in their own words or decline. Use it when a decision belongs to the user and guessing would risk rework.";
40
+ export declare function createAskTool(deps: {
41
+ host: SigrunHost;
42
+ waiters: Waiters;
43
+ }): ToolRegistration<typeof AskParamsSchema>;
package/dist/tool.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The `ask_user_question` tool (spec §7.3–7.5): validate, seed the
3
+ * card, wait for a human — no timer anywhere, the user answers or
4
+ * declines, never a timeout.
5
+ *
6
+ * Order (load-bearing):
7
+ *
8
+ * 1. `validateAsk` — an uncardable call is a tool error with nothing
9
+ * seeded, so the model can fix the call instead of the user
10
+ * answering a broken card.
11
+ * 2. `jobs.isJobRun` — a scheduled job has nobody to ask; error BEFORE
12
+ * seeding so no orphan card lingers.
13
+ * 3. Register the waiter BEFORE seeding: an answer can only commit once
14
+ * the record exists, and the waiter is already listening by then.
15
+ * 4. `seedInteraction` is idempotent per callId — on the `replay:
16
+ * "safe"` rerun after a restart, the first-write-wins commit
17
+ * re-reads the same PENDING record instead of duplicating the card.
18
+ * 5. `snapshotInteraction` once after seeding: covers the answer that
19
+ * committed before the waiter attached, and the answer given while
20
+ * the process was down (the rerun path).
21
+ * 6. Await the waiter.
22
+ * 7. On abort: a settled waiter's answer or decline WON the race and
23
+ * stands; otherwise a clean shutdown (`waiters.shuttingDown`,
24
+ * `harnessShutdown` fires before closeHarness) leaves the record
25
+ * PENDING so the rerun re-seeds it, and any other abort settles the
26
+ * record `cancelled` — inside try/catch, because a closing session
27
+ * rejects the commit and the original abort must still propagate.
28
+ *
29
+ * The execute wrapper's finally discards the waiter entry: a throw
30
+ * BEFORE the await on `pending` (a rejecting seed) never runs
31
+ * `wait()`'s own finally, and without the discard the entry would
32
+ * leak past the failed call.
33
+ */
34
+ import { withoutAbortSignal } from "@earendil-works/chord/context";
35
+ import { defineTool } from "@earendil-works/pi-durable";
36
+ import { ASK_KIND, buildCard } from "./card.js";
37
+ import { formatResult } from "./result.js";
38
+ import { AskParamsSchema, prepareAskArguments } from "./schema.js";
39
+ import { validateAsk } from "./validate.js";
40
+ /** A stored record folded into the waiter's `Settled` shape. */
41
+ function settledOfRecord(record) {
42
+ switch (record.status) {
43
+ case "answered":
44
+ return { status: "answered", values: { ...(record.answer?.values ?? {}) } };
45
+ case "skipped":
46
+ return {
47
+ status: "skipped",
48
+ ...(record.declinedVia !== undefined ? { declinedVia: record.declinedVia } : {}),
49
+ };
50
+ case "expired":
51
+ case "cancelled":
52
+ return { status: record.status };
53
+ default:
54
+ throw new Error("pending is not settled");
55
+ }
56
+ }
57
+ /** The job-run refusal (verbatim; spec §7.5). */
58
+ const JOB_RUN_TEXT = "ask_user_question is not available in scheduled job runs: no one is there to answer. If the escalate tool is available, use it to hand the user the decision instead.";
59
+ /** The tool description the model sees. */
60
+ export const ASK_TOOL_DESCRIPTION = "Ask the user 1-4 structured questions and wait for the answers. Each question has 2-4 options; the user can also answer in their own words or decline. Use it when a decision belongs to the user and guessing would risk rework.";
61
+ /** One sentence, `isError`, no seed. */
62
+ function errorResult(message) {
63
+ return { output: [{ type: "text", text: message }], isError: true };
64
+ }
65
+ export function createAskTool(deps) {
66
+ const { host, waiters } = deps;
67
+ async function executeBody(args, api, context) {
68
+ /**
69
+ * The abort path shared by the snapshot read and the wait. The
70
+ * record is re-read FIRST: a non-pending record means the human's
71
+ * answer or decline won the race and stands. Only a still-pending
72
+ * record is settled `cancelled` (when this abort is not a clean
73
+ * shutdown), so our own settle can never masquerade as the race
74
+ * winner — and the ORIGINAL abort error is what rethrows.
75
+ */
76
+ const onAbort = async (error) => {
77
+ const before = await host
78
+ .snapshotInteraction(api.conversationId, api.callId, withoutAbortSignal(context))
79
+ .catch(() => undefined);
80
+ if (before !== undefined && before.status !== "pending") {
81
+ return { settled: settledOfRecord(before) };
82
+ }
83
+ if (!waiters.shuttingDown) {
84
+ try {
85
+ await host.settleInteraction(api.conversationId, api.callId, "cancelled", withoutAbortSignal(context));
86
+ }
87
+ catch (settleError) {
88
+ console.warn("ask_user_question: cancelling the seeded card failed", settleError);
89
+ }
90
+ }
91
+ return { error };
92
+ };
93
+ const invalid = validateAsk(args);
94
+ if (invalid !== null)
95
+ return errorResult(invalid);
96
+ if (await host.jobs.isJobRun(api.conversationId, context)) {
97
+ return errorResult(JOB_RUN_TEXT);
98
+ }
99
+ const pending = waiters.wait(api.callId, context);
100
+ // Mark the waiter observed NOW: a throw before the await below (a
101
+ // rejecting seed, an aborted snapshot) would otherwise leave its
102
+ // rejection unhandled at shutdown.
103
+ void pending.catch(() => undefined);
104
+ await host.seedInteraction(api.conversationId, {
105
+ kind: ASK_KIND,
106
+ callId: api.callId,
107
+ status: "pending",
108
+ createdAt: Date.now(),
109
+ declinable: true,
110
+ // SAFETY: AskParams is Static<AskParamsSchema> — plain JSON by
111
+ // construction (validated against the schema by the harness), but
112
+ // the typebox interface carries no index signature.
113
+ details: args,
114
+ body: buildCard(args),
115
+ }, context);
116
+ let settled;
117
+ let terminal = false;
118
+ let snap;
119
+ try {
120
+ snap = await host.snapshotInteraction(api.conversationId, api.callId, context);
121
+ }
122
+ catch (error) {
123
+ const outcome = await onAbort(error);
124
+ if ("error" in outcome)
125
+ throw outcome.error;
126
+ settled = outcome.settled;
127
+ terminal = true;
128
+ }
129
+ if (!terminal && snap !== undefined && snap.status !== "pending") {
130
+ settled = settledOfRecord(snap);
131
+ terminal = true;
132
+ }
133
+ if (!terminal) {
134
+ try {
135
+ settled = await pending;
136
+ }
137
+ catch (error) {
138
+ const outcome = await onAbort(error);
139
+ if ("error" in outcome)
140
+ throw outcome.error;
141
+ settled = outcome.settled;
142
+ }
143
+ }
144
+ if (settled === undefined)
145
+ throw new Error("ask_user_question: settled without a settle");
146
+ const result = formatResult(args, settled);
147
+ return {
148
+ output: [{ type: "text", text: result.output }],
149
+ // SAFETY: AskDetails is built here from schema-typed fields only
150
+ // (strings/arrays), hence JSON; its interface lacks an index
151
+ // signature, so it is not structurally a JsonValue.
152
+ details: result.details,
153
+ ...(result.isError ? { isError: true } : {}),
154
+ };
155
+ }
156
+ return defineTool({
157
+ name: "ask_user_question",
158
+ description: ASK_TOOL_DESCRIPTION,
159
+ // Model-only: a code-mode script calling this would block a run no
160
+ // human watches (and its result is never recorded for replay).
161
+ callers: ["model"],
162
+ // Safe to rerun after a restart: the callId-keyed seed is idempotent,
163
+ // and the snapshot read picks up an answer given while down.
164
+ replay: "safe",
165
+ parameters: AskParamsSchema,
166
+ prepareArguments: prepareAskArguments,
167
+ execute: async (args, api, context) => {
168
+ try {
169
+ return await executeBody(args, api, context);
170
+ }
171
+ finally {
172
+ // A throw before the await on `pending` (a rejecting seed, an
173
+ // aborted snapshot) never runs wait()'s own finally — drop the
174
+ // entry here so it cannot leak.
175
+ waiters.discard(api.callId);
176
+ }
177
+ },
178
+ });
179
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The validation rules the schema cannot express (spec §7.1):
3
+ * per-question option-label uniqueness, the reserved free-text labels
4
+ * (the extension always adds that row itself), no preview on
5
+ * multi-select questions, and globally unique headers. Each failure is
6
+ * one actionable sentence — the message is what the model reads, so it
7
+ * says exactly what to change.
8
+ */
9
+ import type { AskParams } from "./schema.js";
10
+ /** The suffix a label may carry to mark the recommended option. */
11
+ export declare const RECOMMENDED_SUFFIX = " (Recommended)";
12
+ /**
13
+ * The first validation failure, or null when the params are card-shaped.
14
+ * Assumes the schema already holds (`AskParams`); the schema's numeric
15
+ * limits are not re-checked here.
16
+ */
17
+ export declare function validateAsk(params: AskParams): string | null;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The validation rules the schema cannot express (spec §7.1):
3
+ * per-question option-label uniqueness, the reserved free-text labels
4
+ * (the extension always adds that row itself), no preview on
5
+ * multi-select questions, and globally unique headers. Each failure is
6
+ * one actionable sentence — the message is what the model reads, so it
7
+ * says exactly what to change.
8
+ */
9
+ /** The suffix a label may carry to mark the recommended option. */
10
+ export const RECOMMENDED_SUFFIX = " (Recommended)";
11
+ /** Labels the card adds itself; offering them as options would duplicate the row. */
12
+ const RESERVED_LABELS = new Set(["Other", "Type something."]);
13
+ /**
14
+ * The first validation failure, or null when the params are card-shaped.
15
+ * Assumes the schema already holds (`AskParams`); the schema's numeric
16
+ * limits are not re-checked here.
17
+ */
18
+ export function validateAsk(params) {
19
+ const headers = new Set();
20
+ for (const [index, question] of params.questions.entries()) {
21
+ const n = index + 1;
22
+ if (headers.has(question.header))
23
+ return "question headers must be unique";
24
+ headers.add(question.header);
25
+ const labels = new Set();
26
+ for (const option of question.options) {
27
+ // Compare on the rendered label: the recommended suffix is stripped
28
+ // before the card draws the option, so "A" and "A (Recommended)" are
29
+ // the same label, and "Other (Recommended)" is still reserved.
30
+ const label = option.label.endsWith(RECOMMENDED_SUFFIX)
31
+ ? option.label.slice(0, -RECOMMENDED_SUFFIX.length)
32
+ : option.label;
33
+ if (labels.has(label))
34
+ return `question ${n}: option labels must be unique`;
35
+ labels.add(label);
36
+ if (RESERVED_LABELS.has(label)) {
37
+ return `question ${n}: "${label}" is reserved — the free-text row is added automatically`;
38
+ }
39
+ if (question.multiSelect === true && option.preview !== undefined) {
40
+ return `question ${n}: preview is only allowed on single-select questions`;
41
+ }
42
+ }
43
+ }
44
+ return null;
45
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The waiter registry: the `interactionSettled` lifecycle handler
3
+ * resolves the promise the tool registered under the same callId
4
+ * (spec §7.4). An event for another kind, or one with no waiter —
5
+ * replayed history, a card answered while the process was down, a
6
+ * settle that raced the waiter away — is dropped: the tool re-reads
7
+ * the record itself via `snapshotInteraction`, so nothing is lost.
8
+ *
9
+ * `shuttingDown` is the flag `harnessShutdown` sets BEFORE the harness
10
+ * closes. The tool's abort path reads it: a wait interrupted with the
11
+ * flag set is a clean restart, and the record stays PENDING so the
12
+ * rerun after restart seeds the same card again (spec §7.5); without
13
+ * the flag the abort is a real cancellation and the record settles
14
+ * `cancelled`. NO timers: a question never expires on its own (ruling:
15
+ * the user answers or declines, never a timeout).
16
+ */
17
+ import type { Context } from "@earendil-works/chord/context";
18
+ import type { JsonValue } from "@earendil-works/chord";
19
+ import type { ExtensionLifecyclePayload } from "@sigrun/sigrun/extension";
20
+ /** The terminal outcome of one wait. */
21
+ export type Settled = {
22
+ status: "answered";
23
+ values: Record<string, JsonValue>;
24
+ } | {
25
+ status: "skipped";
26
+ declinedVia?: "button" | "message";
27
+ } | {
28
+ status: "expired" | "cancelled";
29
+ };
30
+ type SettleEvent = ExtensionLifecyclePayload["interactionSettled"];
31
+ /** Fold a lifecycle event into the waiter's `Settled` shape. */
32
+ export declare function settledOf(event: SettleEvent): Settled;
33
+ export declare class Waiters {
34
+ #private;
35
+ /** True once the harness shutdown sequence starts; read by the abort path. */
36
+ shuttingDown: boolean;
37
+ /**
38
+ * The promise the tool awaits: resolves on `settle` with the same
39
+ * callId, rejects when `context` aborts. Register BEFORE seeding the
40
+ * card — an answer committed between seeding and registration would
41
+ * otherwise be missed (the tool's snapshot read is the belt to this
42
+ * suspender).
43
+ */
44
+ wait(callId: string, context: Context): Promise<Settled>;
45
+ /**
46
+ * Delete the waiter for `callId` outright. Called from the tool's
47
+ * execute-body finally so a throw BEFORE the await (a rejecting seed)
48
+ * cannot leak the entry. No identity guard is needed here: a callId
49
+ * belongs to exactly one in-flight execute, and the only
50
+ * re-registration under the same callId is the post-restart rerun,
51
+ * which registers after this execute has finished.
52
+ */
53
+ discard(callId: string): void;
54
+ /** The `interactionSettled` handler: resolves the matching waiter, drops everything else. */
55
+ settle(event: SettleEvent): void;
56
+ }
57
+ export {};
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The waiter registry: the `interactionSettled` lifecycle handler
3
+ * resolves the promise the tool registered under the same callId
4
+ * (spec §7.4). An event for another kind, or one with no waiter —
5
+ * replayed history, a card answered while the process was down, a
6
+ * settle that raced the waiter away — is dropped: the tool re-reads
7
+ * the record itself via `snapshotInteraction`, so nothing is lost.
8
+ *
9
+ * `shuttingDown` is the flag `harnessShutdown` sets BEFORE the harness
10
+ * closes. The tool's abort path reads it: a wait interrupted with the
11
+ * flag set is a clean restart, and the record stays PENDING so the
12
+ * rerun after restart seeds the same card again (spec §7.5); without
13
+ * the flag the abort is a real cancellation and the record settles
14
+ * `cancelled`. NO timers: a question never expires on its own (ruling:
15
+ * the user answers or declines, never a timeout).
16
+ */
17
+ import { awaitWithContext } from "@earendil-works/chord/context";
18
+ import { ASK_KIND } from "./card.js";
19
+ /** Fold a lifecycle event into the waiter's `Settled` shape. */
20
+ export function settledOf(event) {
21
+ switch (event.status) {
22
+ case "answered":
23
+ // The event's `answer` is the record's PLAIN values map (core
24
+ // event-bus pushes `record.answer.values`); the values are
25
+ // render-safe card output, hence JsonValue-shaped.
26
+ return {
27
+ status: "answered",
28
+ values: { ...(event.answer ?? {}) },
29
+ };
30
+ case "skipped":
31
+ return {
32
+ status: "skipped",
33
+ ...(event.declinedVia !== undefined ? { declinedVia: event.declinedVia } : {}),
34
+ };
35
+ case "expired":
36
+ case "cancelled":
37
+ return { status: event.status };
38
+ }
39
+ }
40
+ export class Waiters {
41
+ /** True once the harness shutdown sequence starts; read by the abort path. */
42
+ shuttingDown = false;
43
+ #waiters = new Map();
44
+ /**
45
+ * The promise the tool awaits: resolves on `settle` with the same
46
+ * callId, rejects when `context` aborts. Register BEFORE seeding the
47
+ * card — an answer committed between seeding and registration would
48
+ * otherwise be missed (the tool's snapshot read is the belt to this
49
+ * suspender).
50
+ */
51
+ wait(callId, context) {
52
+ let resolveFn = () => undefined;
53
+ const promise = new Promise((resolve) => {
54
+ resolveFn = resolve;
55
+ });
56
+ this.#waiters.set(callId, resolveFn);
57
+ // Drop the map entry when the wait ends for ANY reason — an aborted
58
+ // wait never sees a settle, and a stale resolver would swallow a
59
+ // later call's event under a reused callId. Only OUR entry is
60
+ // removed: settle() deletes on the resolve path, and a re-registered
61
+ // waiter for the same callId owns its own entry.
62
+ return awaitWithContext(promise, context).finally(() => {
63
+ if (this.#waiters.get(callId) === resolveFn)
64
+ this.#waiters.delete(callId);
65
+ });
66
+ }
67
+ /**
68
+ * Delete the waiter for `callId` outright. Called from the tool's
69
+ * execute-body finally so a throw BEFORE the await (a rejecting seed)
70
+ * cannot leak the entry. No identity guard is needed here: a callId
71
+ * belongs to exactly one in-flight execute, and the only
72
+ * re-registration under the same callId is the post-restart rerun,
73
+ * which registers after this execute has finished.
74
+ */
75
+ discard(callId) {
76
+ this.#waiters.delete(callId);
77
+ }
78
+ /** The `interactionSettled` handler: resolves the matching waiter, drops everything else. */
79
+ settle(event) {
80
+ if (event.kind !== ASK_KIND)
81
+ return;
82
+ const resolve = this.#waiters.get(event.callId);
83
+ if (resolve === undefined)
84
+ return;
85
+ this.#waiters.delete(event.callId);
86
+ resolve(settledOf(event));
87
+ }
88
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@sigrun/ask-user",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Lets the agent ask the user structured questions and wait for the answer",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "CHANGELOG.md",
20
+ "THIRD_PARTY_NOTICES.md"
21
+ ],
22
+ "scripts": {
23
+ "test": "node scripts/check-manifest.mjs --expect-name @sigrun/ask-user && vitest run",
24
+ "typecheck": "tsc --noEmit",
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/chord": "1.3.0 || 1.4.0",
30
+ "@earendil-works/pi-ai": "1.3.0 || 1.4.0",
31
+ "@earendil-works/pi-durable": "1.3.0 || 1.4.0"
32
+ },
33
+ "dependencies": {},
34
+ "devDependencies": {
35
+ "@earendil-works/chord": "1.4.0",
36
+ "@earendil-works/pi-ai": "1.4.0",
37
+ "@earendil-works/pi-durable": "1.4.0",
38
+ "@sigrun/shared": "file:../sigrun/shared",
39
+ "@sigrun/sigrun": "file:../sigrun",
40
+ "@types/node": "^26.6.4",
41
+ "tsx": "^4.23.15",
42
+ "typebox": "1.3.27",
43
+ "typescript": "^7.0.2",
44
+ "vitest": "^5.0.3"
45
+ },
46
+ "license": "MIT",
47
+ "repository": {
48
+ "type": "github",
49
+ "url": "git+https://github.com/sigrun-ai/sigrun-ask-user.git"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/sigrun-ai/sigrun-ask-user/issues"
53
+ },
54
+ "homepage": "https://github.com/sigrun-ai/sigrun-ask-user#readme",
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "provenance": true
58
+ }
59
+ }