@pi9/ask 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/package.json +1 -1
- package/src/component.ts +91 -38
- package/src/deadline.ts +29 -4
- package/src/domain.ts +144 -0
- package/src/index.ts +78 -76
- package/src/questionnaire.ts +2 -2
- package/src/rpc.ts +24 -38
- package/src/session.ts +254 -0
- package/src/state.ts +21 -25
- package/src/config.ts +0 -30
- package/src/context.ts +0 -209
- package/src/replay-renderer.ts +0 -85
- package/src/replay.ts +0 -100
- package/src/response.ts +0 -31
- package/src/schema.ts +0 -43
- package/src/types.ts +0 -33
- package/src/validation.ts +0 -48
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ Ask adds interactive questions to [Pi](https://github.com/earendil-works/pi-mono
|
|
|
5
5
|
## Feature overview
|
|
6
6
|
|
|
7
7
|
- **Answer revisions** — Reopen an Ask entry from `/tree` and continue with a new answer.
|
|
8
|
+
- **Tool-call rewriting** — Replace answered standalone Ask exchanges in model context with compact, decision-focused responses while preserving the original session history.
|
|
8
9
|
- **Lean tool definition** — A highly optimized description and schema minimize prompt overhead while staying compatible across providers.
|
|
9
10
|
- **Rich previews** — Inspect Markdown previews before choosing.
|
|
10
11
|
- **Multi-select** — Choose any number of options, then submit them together.
|
|
@@ -61,6 +62,16 @@ Comments let you qualify a selection without writing a separate message. When a
|
|
|
61
62
|
|
|
62
63
|
## Advanced behavior
|
|
63
64
|
|
|
65
|
+
### Tool-call rewriting
|
|
66
|
+
|
|
67
|
+
Ask rewrites answered standalone exchanges before they are sent back to the model, replacing the Ask call and its answer record with a compact `ask_response` message. The replacement keeps the question, optional background, single- or multi-select mode, selected option labels and descriptions, comments, and any freeform response. It leaves out unselected options and all Markdown previews.
|
|
68
|
+
|
|
69
|
+
The main purpose is to keep proposed alternatives from being mistaken for decisions. Once you choose an answer, the model sees only what you selected and the context attached to that selection—not every option it originally offered. As a secondary benefit, dropping unused answers, previews, and the surrounding tool-call protocol reduces the amount of context carried into later turns.
|
|
70
|
+
|
|
71
|
+
The answer can come from either a successful native tool result or a valid revision submitted from `/tree`. A revision becomes the authoritative answer, replacing the earlier result in model context even if that result is absent or unsuccessful.
|
|
72
|
+
|
|
73
|
+
This rewrite affects only the model-facing context. The original messages remain in the stored session, so the conversation still renders normally and the question can still be reopened from `/tree`. Ask rewrites only unambiguous calls and answers that validate against each other; otherwise, it leaves the messages untouched rather than risk producing a misleading response or an unmatched tool call.
|
|
74
|
+
|
|
64
75
|
### Responsive previews
|
|
65
76
|
|
|
66
77
|
Previews appear beside the options in terminals at least 88 columns wide and stack below them in narrower layouts. They support Markdown and update as you move between options. While you edit a comment or freeform response, the preview is hidden to leave more room for writing.
|
package/package.json
CHANGED
package/src/component.ts
CHANGED
|
@@ -19,9 +19,9 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
19
19
|
import {
|
|
20
20
|
createQuestionnaireState,
|
|
21
21
|
transitionQuestionnaire,
|
|
22
|
+
type QuestionnaireRow,
|
|
22
23
|
type QuestionnaireState,
|
|
23
24
|
} from "./state.js";
|
|
24
|
-
import { CHECKED_BOX, EMPTY_BOX } from "./glyphs.js";
|
|
25
25
|
import {
|
|
26
26
|
composePreviewRow,
|
|
27
27
|
getPreviewPaneLayout,
|
|
@@ -33,9 +33,11 @@ import {
|
|
|
33
33
|
type FocusRange,
|
|
34
34
|
type ViewportOverflow,
|
|
35
35
|
} from "./viewport.js";
|
|
36
|
-
import type {
|
|
36
|
+
import type { Ask, AskAnswer } from "./domain.js";
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
const FRAME_WIDE_PREVIEW = true; // Set to false to compare the same layout without its outer frame.
|
|
39
|
+
|
|
40
|
+
type AskComponentOptions = Ask & {
|
|
39
41
|
tui: TUI;
|
|
40
42
|
theme: Theme;
|
|
41
43
|
keybindings: KeybindingsManager;
|
|
@@ -166,7 +168,14 @@ export class AskComponent implements Component, Focusable {
|
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
render(width: number): string[] {
|
|
169
|
-
const
|
|
171
|
+
const availableWidth = safeWidth(width);
|
|
172
|
+
const hasAuthoredPreviews = this.questionnaireState.rows.some(
|
|
173
|
+
row => row.kind === "option" && row.option.preview?.trim(),
|
|
174
|
+
);
|
|
175
|
+
const framed = FRAME_WIDE_PREVIEW
|
|
176
|
+
&& hasAuthoredPreviews
|
|
177
|
+
&& getPreviewPaneLayout(availableWidth - 4) !== undefined;
|
|
178
|
+
const renderWidth = framed ? availableWidth - 4 : availableWidth;
|
|
170
179
|
const lines: string[] = [];
|
|
171
180
|
const add = (line: string) => lines.push(fit(line, renderWidth));
|
|
172
181
|
const addPrefixed = (prefix: string, text: string, color?: "text" | "muted" | "dim" | "accent") => {
|
|
@@ -176,11 +185,8 @@ export class AskComponent implements Component, Focusable {
|
|
|
176
185
|
|
|
177
186
|
add(this.config.theme.fg("border", "─".repeat(renderWidth)));
|
|
178
187
|
|
|
179
|
-
if (this.config.context) {
|
|
180
|
-
addPrefixed(" ", this.config.context, "muted");
|
|
181
|
-
add("");
|
|
182
|
-
}
|
|
183
188
|
addPrefixed(" ", this.config.theme.bold(this.config.question), "text");
|
|
189
|
+
if (this.config.context) addPrefixed(" ", this.config.context, "muted");
|
|
184
190
|
add("");
|
|
185
191
|
|
|
186
192
|
let focus: FocusRange | undefined;
|
|
@@ -198,16 +204,18 @@ export class AskComponent implements Component, Focusable {
|
|
|
198
204
|
};
|
|
199
205
|
if (this.questionnaireState.editor.kind === "select") {
|
|
200
206
|
const preview = this.highlightedPreview();
|
|
201
|
-
const
|
|
202
|
-
row => row.kind === "option" && row.option.preview?.trim(),
|
|
203
|
-
);
|
|
204
|
-
const paneLayout = hasPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
|
|
207
|
+
const paneLayout = hasAuthoredPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
|
|
205
208
|
if (paneLayout) {
|
|
206
|
-
const optionLines
|
|
209
|
+
const optionLines = [this.config.theme.fg("dim", " OPTIONS"), ""];
|
|
207
210
|
const optionFocus = this.renderOptions(optionLines, paneLayout.leftWidth);
|
|
208
211
|
const submitFocus = this.renderSubmit(optionLines, paneLayout.leftWidth);
|
|
209
212
|
const sectionStart = lines.length;
|
|
210
|
-
const previewLines =
|
|
213
|
+
const previewLines = [
|
|
214
|
+
this.config.theme.fg("dim", " PREVIEW · FOCUSED OPTION"),
|
|
215
|
+
this.config.theme.fg("dim", "─".repeat(paneLayout.rightWidth)),
|
|
216
|
+
"",
|
|
217
|
+
...this.renderPreview(preview, paneLayout.rightWidth - 1).map(line => ` ${line}`),
|
|
218
|
+
];
|
|
211
219
|
const bodyHeight = Math.max(optionLines.length, previewLines.length);
|
|
212
220
|
lines.push(...optionLines, ...Array.from({ length: bodyHeight - optionLines.length }, () => ""));
|
|
213
221
|
wideBody = {
|
|
@@ -226,7 +234,7 @@ export class AskComponent implements Component, Focusable {
|
|
|
226
234
|
} else {
|
|
227
235
|
const optionFocus = this.renderOptions(lines, renderWidth);
|
|
228
236
|
focus = optionFocus;
|
|
229
|
-
if (
|
|
237
|
+
if (hasAuthoredPreviews) {
|
|
230
238
|
const previewStart = lines.length;
|
|
231
239
|
const previewLines = this.renderPreview(preview, renderWidth);
|
|
232
240
|
lines.push(...previewLines);
|
|
@@ -247,28 +255,57 @@ export class AskComponent implements Component, Focusable {
|
|
|
247
255
|
footerStart = lines.length;
|
|
248
256
|
addFooter(" ", this.config.theme.fg("dim", this.helpText()));
|
|
249
257
|
} else {
|
|
258
|
+
const activeEditor = this.questionnaireState.editor;
|
|
250
259
|
// Keep the options visible while editing so the comment remains tied to
|
|
251
260
|
// the option it belongs to, and so freeform editing does not feel like a
|
|
252
261
|
// separate prompt.
|
|
253
|
-
|
|
262
|
+
const paneLayout = hasAuthoredPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
|
|
263
|
+
const optionLines = paneLayout
|
|
264
|
+
? [this.config.theme.fg("dim", " OPTIONS"), ""]
|
|
265
|
+
: lines;
|
|
266
|
+
const optionWidth = paneLayout?.leftWidth ?? renderWidth;
|
|
254
267
|
const inputPrefix = ` ${this.config.theme.fg("accent", "↳")} `;
|
|
255
268
|
const continuationPrefix = " ".repeat(visibleWidth(inputPrefix));
|
|
256
|
-
const inputWidth = Math.max(1,
|
|
269
|
+
const inputWidth = Math.max(1, optionWidth - visibleWidth(inputPrefix));
|
|
257
270
|
const editorLines = this.editor.render(inputWidth).filter(line => visibleWidth(line) > 0);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
271
|
+
let editorStart = -1;
|
|
272
|
+
let editorEnd = -1;
|
|
273
|
+
this.renderOptions(optionLines, optionWidth, (row) => {
|
|
274
|
+
if (row !== activeEditor.target) return;
|
|
275
|
+
editorStart = optionLines.length;
|
|
276
|
+
for (const [index, line] of editorLines.entries()) {
|
|
277
|
+
optionLines.push(fit(`${index === 0 ? inputPrefix : continuationPrefix}${line}`, optionWidth));
|
|
278
|
+
}
|
|
279
|
+
editorEnd = optionLines.length;
|
|
280
|
+
});
|
|
263
281
|
if (editorEnd > editorStart) {
|
|
264
282
|
const cursorLine = editorLines.findIndex(line => line.includes(CURSOR_MARKER));
|
|
265
283
|
const focusedRow = cursorLine >= 0 ? editorStart + cursorLine : editorEnd - 1;
|
|
266
284
|
focus = { start: focusedRow, end: focusedRow + 1 };
|
|
267
285
|
}
|
|
268
286
|
|
|
287
|
+
this.renderSubmit(optionLines, optionWidth);
|
|
288
|
+
if (paneLayout) {
|
|
289
|
+
const sectionStart = lines.length;
|
|
290
|
+
const previewLines = [
|
|
291
|
+
this.config.theme.fg("dim", " PREVIEW · FOCUSED OPTION"),
|
|
292
|
+
this.config.theme.fg("dim", "─".repeat(paneLayout.rightWidth)),
|
|
293
|
+
"",
|
|
294
|
+
...this.renderPreview(undefined, paneLayout.rightWidth - 1).map(line => ` ${line}`),
|
|
295
|
+
];
|
|
296
|
+
const bodyHeight = Math.max(optionLines.length, previewLines.length);
|
|
297
|
+
lines.push(...optionLines, ...Array.from({ length: bodyHeight - optionLines.length }, () => ""));
|
|
298
|
+
wideBody = {
|
|
299
|
+
start: sectionStart,
|
|
300
|
+
end: sectionStart + bodyHeight,
|
|
301
|
+
previewLines,
|
|
302
|
+
layout: paneLayout,
|
|
303
|
+
};
|
|
304
|
+
if (focus) focus = { start: sectionStart + focus.start, end: sectionStart + focus.end };
|
|
305
|
+
}
|
|
306
|
+
|
|
269
307
|
// The help line is the footer. Keep it adjacent to the bottom border so
|
|
270
308
|
// fitViewport can keep both rows fixed while the editor is focused.
|
|
271
|
-
this.renderSubmit(lines, renderWidth);
|
|
272
309
|
footerStart = lines.length;
|
|
273
310
|
addFooter(continuationPrefix, this.config.theme.fg("dim", this.editorHelpText()));
|
|
274
311
|
}
|
|
@@ -287,7 +324,7 @@ export class AskComponent implements Component, Focusable {
|
|
|
287
324
|
const visibleRows = fitViewport(rows, focus, maxRows, 1, lines.length - footerStart);
|
|
288
325
|
let previewIndex = 0;
|
|
289
326
|
|
|
290
|
-
|
|
327
|
+
const projected = visibleRows.map(({ value: row, overflow }) => {
|
|
291
328
|
if (row.kind === "full") {
|
|
292
329
|
return projectFullRow(row.line, overflow, renderWidth);
|
|
293
330
|
}
|
|
@@ -301,6 +338,7 @@ export class AskComponent implements Component, Focusable {
|
|
|
301
338
|
this.config.theme.fg("dim", "│"),
|
|
302
339
|
);
|
|
303
340
|
});
|
|
341
|
+
return framed ? frameDialog(projected, availableWidth, this.config.theme) : projected;
|
|
304
342
|
}
|
|
305
343
|
|
|
306
344
|
/** Submit the current multi-select answer programmatically. */
|
|
@@ -320,7 +358,11 @@ export class AskComponent implements Component, Focusable {
|
|
|
320
358
|
this.config.onCancel?.();
|
|
321
359
|
}
|
|
322
360
|
|
|
323
|
-
private renderOptions(
|
|
361
|
+
private renderOptions(
|
|
362
|
+
lines: string[],
|
|
363
|
+
width: number,
|
|
364
|
+
afterRow?: (row: QuestionnaireRow) => void,
|
|
365
|
+
): FocusRange | undefined {
|
|
324
366
|
const addPrefixed = (prefix: string, text: string, color?: "text" | "muted" | "dim" | "accent") => {
|
|
325
367
|
const styled = color ? this.config.theme.fg(color, text) : text;
|
|
326
368
|
addWrappedWithPrefix(lines, prefix, styled, width);
|
|
@@ -331,34 +373,34 @@ export class AskComponent implements Component, Focusable {
|
|
|
331
373
|
if (row.kind === "submit") continue;
|
|
332
374
|
const selected = this.questionnaireState.highlightedRow === index;
|
|
333
375
|
const start = lines.length;
|
|
334
|
-
const marker = selected ? this.config.theme.fg("accent", "
|
|
376
|
+
const marker = selected ? this.config.theme.fg("accent", "┃ ") : " ";
|
|
335
377
|
|
|
336
378
|
if (row.kind === "option") {
|
|
337
379
|
const source = row.option;
|
|
338
|
-
const checked = this.questionnaireState.checked.has(
|
|
339
|
-
const
|
|
340
|
-
?
|
|
380
|
+
const checked = this.questionnaireState.checked.has(row.index);
|
|
381
|
+
const badge = this.questionnaireState.config.allowMultiple && checked
|
|
382
|
+
? this.config.theme.fg("success", " [selected]")
|
|
341
383
|
: "";
|
|
342
|
-
const comment = this.questionnaireState.comments.has(
|
|
384
|
+
const comment = this.questionnaireState.comments.has(row.index)
|
|
343
385
|
? this.config.theme.fg("warning", " ✎")
|
|
344
386
|
: "";
|
|
345
|
-
addPrefixed(
|
|
346
|
-
if (source.description) addPrefixed("
|
|
387
|
+
addPrefixed(marker, `${source.label}${badge}${comment}`, selected ? "accent" : "text");
|
|
388
|
+
if (source.description) addPrefixed(" ", source.description, "muted");
|
|
347
389
|
if (selected) {
|
|
348
|
-
const commentText = this.questionnaireState.comments.get(
|
|
390
|
+
const commentText = this.questionnaireState.comments.get(row.index);
|
|
349
391
|
if (commentText) addPrefixed(" ", `✎ ${commentText}`, "dim");
|
|
350
392
|
}
|
|
351
393
|
} else {
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
? `${this.config.theme.fg(checked ? "success" : "muted", checked ? CHECKED_BOX : EMPTY_BOX)} `
|
|
394
|
+
const badge = this.questionnaireState.config.allowMultiple && this.questionnaireState.freeformChecked
|
|
395
|
+
? this.config.theme.fg("success", " [selected]")
|
|
355
396
|
: "";
|
|
356
397
|
const suffix = this.questionnaireState.freeformDraft
|
|
357
398
|
? ` — ${this.questionnaireState.freeformDraft}`
|
|
358
399
|
: "";
|
|
359
|
-
addPrefixed(
|
|
400
|
+
addPrefixed(marker, `Type a response…${suffix}${badge}`, selected ? "accent" : "text");
|
|
360
401
|
}
|
|
361
402
|
if (selected) focus = { start, end: lines.length };
|
|
403
|
+
afterRow?.(row);
|
|
362
404
|
}
|
|
363
405
|
return focus;
|
|
364
406
|
}
|
|
@@ -367,7 +409,7 @@ export class AskComponent implements Component, Focusable {
|
|
|
367
409
|
const submitIndex = this.questionnaireState.rows.findIndex(row => row.kind === "submit");
|
|
368
410
|
if (submitIndex < 0) return undefined;
|
|
369
411
|
const selected = this.questionnaireState.highlightedRow === submitIndex;
|
|
370
|
-
const marker = selected ? this.config.theme.fg("accent", "
|
|
412
|
+
const marker = selected ? this.config.theme.fg("accent", "┃ ") : " ";
|
|
371
413
|
lines.push("");
|
|
372
414
|
const start = lines.length;
|
|
373
415
|
addWrappedWithPrefix(
|
|
@@ -530,6 +572,17 @@ function overflowPrefix(overflow: ViewportOverflow | undefined): string {
|
|
|
530
572
|
return "";
|
|
531
573
|
}
|
|
532
574
|
|
|
575
|
+
function frameDialog(lines: readonly string[], width: number, theme: Theme): string[] {
|
|
576
|
+
const border = (text: string) => theme.fg("border", text);
|
|
577
|
+
const innerWidth = width - 4;
|
|
578
|
+
return lines.map((line, index) => {
|
|
579
|
+
if (index === 0) return border(`╭${"─".repeat(width - 2)}╮`);
|
|
580
|
+
if (index === lines.length - 1) return border(`╰${"─".repeat(width - 2)}╯`);
|
|
581
|
+
const content = fit(line, innerWidth);
|
|
582
|
+
return `${border("│")} ${content}${" ".repeat(innerWidth - visibleWidth(content))} ${border("│")}`;
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
533
586
|
function safeWidth(width: number): number {
|
|
534
587
|
return Math.max(1, Number.isFinite(width) ? Math.floor(width) : 1);
|
|
535
588
|
}
|
package/src/deadline.ts
CHANGED
|
@@ -1,16 +1,41 @@
|
|
|
1
|
+
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
2
|
+
|
|
3
|
+
type AskEnvironment = Readonly<{
|
|
4
|
+
PI9_ASK_TIMEOUT_MS?: string;
|
|
5
|
+
}>;
|
|
6
|
+
|
|
1
7
|
export interface DeadlineSignal {
|
|
2
8
|
signal: AbortSignal | undefined;
|
|
3
9
|
readonly timedOut: boolean;
|
|
4
10
|
dispose(): void;
|
|
5
11
|
}
|
|
6
12
|
|
|
13
|
+
export function resolveTimeoutMs(
|
|
14
|
+
perCallTimeout: number | undefined,
|
|
15
|
+
env: AskEnvironment,
|
|
16
|
+
): number | undefined {
|
|
17
|
+
if (perCallTimeout !== undefined) {
|
|
18
|
+
return Number.isInteger(perCallTimeout) && perCallTimeout > 0 && perCallTimeout <= MAX_TIMEOUT_MS
|
|
19
|
+
? perCallTimeout
|
|
20
|
+
: undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const envTimeout = env.PI9_ASK_TIMEOUT_MS;
|
|
24
|
+
if (envTimeout === undefined || !/^\d+$/.test(envTimeout)) return undefined;
|
|
25
|
+
|
|
26
|
+
const timeout = Number(envTimeout);
|
|
27
|
+
return Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS
|
|
28
|
+
? timeout
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
7
32
|
export function createDeadlineSignal(
|
|
8
33
|
parent: AbortSignal | undefined,
|
|
9
|
-
|
|
34
|
+
perCallTimeout: number | undefined,
|
|
35
|
+
env: AskEnvironment = {},
|
|
10
36
|
): DeadlineSignal {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
&& timeoutMs > 0;
|
|
37
|
+
const timeoutMs = resolveTimeoutMs(perCallTimeout, env);
|
|
38
|
+
const hasTimeout = timeoutMs !== undefined;
|
|
14
39
|
|
|
15
40
|
if (parent === undefined && !hasTimeout) {
|
|
16
41
|
return {
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { Check } from "typebox/value";
|
|
3
|
+
|
|
4
|
+
import { MAX_TIMEOUT_MS } from "./deadline.js";
|
|
5
|
+
|
|
6
|
+
export const AskOptionSchema = Type.Object({
|
|
7
|
+
label: Type.String({ minLength: 1, description: "Short option title; keep distinct across options." }),
|
|
8
|
+
description: Type.Optional(Type.String({ description: "Brief explanation when the label alone is ambiguous." })),
|
|
9
|
+
preview: Type.Optional(Type.String({ description: "Markdown shown to the user, e.g. code, plans, or comparisons; not returned in the answer." })),
|
|
10
|
+
}, { additionalProperties: false });
|
|
11
|
+
|
|
12
|
+
export const AskParamsSchema = Type.Object({
|
|
13
|
+
question: Type.String({ minLength: 1 }),
|
|
14
|
+
context: Type.Optional(Type.String({ description: "Optional background shown above the question, e.g. what prompted the ask." })),
|
|
15
|
+
options: Type.Array(AskOptionSchema, { minItems: 1, description: "Suggested answers." }),
|
|
16
|
+
allowMultiple: Type.Optional(Type.Boolean({ default: false, description: "Allow selecting multiple options. Defaults to false." })),
|
|
17
|
+
allowFreeform: Type.Optional(Type.Boolean({ default: true, description: "Allow a typed response. Defaults to true." })),
|
|
18
|
+
timeout: Type.Optional(Type.Integer({ minimum: 0, maximum: MAX_TIMEOUT_MS, description: "Timeout in ms for answers that would go stale; the call returns unanswered on expiry. Zero disables any default." })),
|
|
19
|
+
}, { additionalProperties: false });
|
|
20
|
+
|
|
21
|
+
export const AskSelectionSchema = Type.Object({
|
|
22
|
+
option: Type.Integer({ minimum: 0 }),
|
|
23
|
+
comment: Type.Optional(Type.String({ minLength: 1 })),
|
|
24
|
+
}, { additionalProperties: false });
|
|
25
|
+
|
|
26
|
+
export const AskAnswerSchema = Type.Object({
|
|
27
|
+
selections: Type.Array(AskSelectionSchema),
|
|
28
|
+
freeform: Type.Optional(Type.String({ minLength: 1 })),
|
|
29
|
+
}, { additionalProperties: false });
|
|
30
|
+
|
|
31
|
+
export const AskReplayDetailsSchema = Type.Object({
|
|
32
|
+
toolCallId: Type.String({ minLength: 1 }),
|
|
33
|
+
answer: AskAnswerSchema,
|
|
34
|
+
}, { additionalProperties: false });
|
|
35
|
+
|
|
36
|
+
export const AskAnsweredDetailsSchema = Type.Object({
|
|
37
|
+
status: Type.Literal("answered"),
|
|
38
|
+
answer: AskAnswerSchema,
|
|
39
|
+
}, { additionalProperties: false });
|
|
40
|
+
|
|
41
|
+
export type AskOption = Static<typeof AskOptionSchema>;
|
|
42
|
+
export type AskParams = Static<typeof AskParamsSchema>;
|
|
43
|
+
export type AskSelection = Static<typeof AskSelectionSchema>;
|
|
44
|
+
export type AskAnswer = Static<typeof AskAnswerSchema>;
|
|
45
|
+
export type AskReplayDetails = Static<typeof AskReplayDetailsSchema>;
|
|
46
|
+
export type AskAnsweredDetails = Static<typeof AskAnsweredDetailsSchema>;
|
|
47
|
+
|
|
48
|
+
export type Ask = Omit<AskParams, "allowMultiple" | "allowFreeform"> & {
|
|
49
|
+
allowMultiple: boolean;
|
|
50
|
+
allowFreeform: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type AskToolDetails =
|
|
54
|
+
| AskAnsweredDetails
|
|
55
|
+
| { status: "unanswered" | "cancelled" | "ui_unavailable" };
|
|
56
|
+
|
|
57
|
+
export type AskResponse = {
|
|
58
|
+
content: Array<{ type: "text"; text: string }>;
|
|
59
|
+
details: AskToolDetails;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function normalizeAsk(params: AskParams): Ask {
|
|
63
|
+
const question = params.question.trim();
|
|
64
|
+
if (!question) throw new Error("Ask question must not be empty.");
|
|
65
|
+
|
|
66
|
+
const context = trimOptional(params.context);
|
|
67
|
+
const options = params.options.map(normalizeOption);
|
|
68
|
+
if (options.length === 0) throw new Error("Ask needs at least one option.");
|
|
69
|
+
|
|
70
|
+
const labels = new Set<string>();
|
|
71
|
+
for (const option of options) {
|
|
72
|
+
if (labels.has(option.label)) throw new Error(`Ask options contain duplicate label: ${option.label}.`);
|
|
73
|
+
labels.add(option.label);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
question,
|
|
78
|
+
...(context === undefined ? {} : { context }),
|
|
79
|
+
options,
|
|
80
|
+
allowMultiple: params.allowMultiple ?? false,
|
|
81
|
+
allowFreeform: params.allowFreeform ?? true,
|
|
82
|
+
...(params.timeout === undefined ? {} : { timeout: params.timeout }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseAskReplayDetails(value: unknown): AskReplayDetails | undefined {
|
|
87
|
+
return Check(AskReplayDetailsSchema, value) ? value : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function answerMatchesAsk(answer: AskAnswer, ask: Ask): boolean {
|
|
91
|
+
if (answer.selections.length > 1 && !ask.allowMultiple) return false;
|
|
92
|
+
if (answer.freeform !== undefined && !ask.allowFreeform) return false;
|
|
93
|
+
|
|
94
|
+
const selected = new Set<number>();
|
|
95
|
+
for (const selection of answer.selections) {
|
|
96
|
+
if (selection.option >= ask.options.length || selected.has(selection.option)) return false;
|
|
97
|
+
selected.add(selection.option);
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatAskAnswer(ask: Ask, answer: AskAnswer): string {
|
|
103
|
+
const lines = answer.selections.map((selection) => {
|
|
104
|
+
const option = ask.options[selection.option]!;
|
|
105
|
+
const description = option.description ? ` — ${option.description}` : "";
|
|
106
|
+
const comment = selection.comment ? ` (${selection.comment})` : "";
|
|
107
|
+
return `Selected: ${option.label}${description}${comment}`;
|
|
108
|
+
});
|
|
109
|
+
if (answer.freeform) lines.push(`Freeform: ${answer.freeform}`);
|
|
110
|
+
return lines.join("\n") || "No answer provided.";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildAskResponse(ask: Ask, details: AskToolDetails): AskResponse {
|
|
114
|
+
if (details.status === "answered") {
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: formatAskAnswer(ask, details.answer) }],
|
|
117
|
+
details,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const text = {
|
|
122
|
+
unanswered: "The question timed out without an answer.",
|
|
123
|
+
cancelled: "User cancelled the question.",
|
|
124
|
+
ui_unavailable: "Interactive UI is unavailable.",
|
|
125
|
+
}[details.status];
|
|
126
|
+
return { content: [{ type: "text", text }], details };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeOption(option: AskOption): AskOption {
|
|
130
|
+
const label = option.label.trim();
|
|
131
|
+
if (!label) throw new Error("Ask option label must not be empty.");
|
|
132
|
+
const description = trimOptional(option.description);
|
|
133
|
+
const preview = option.preview?.trim() ? option.preview : undefined;
|
|
134
|
+
return {
|
|
135
|
+
label,
|
|
136
|
+
...(description === undefined ? {} : { description }),
|
|
137
|
+
...(preview === undefined ? {} : { preview }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function trimOptional(value: string | undefined): string | undefined {
|
|
142
|
+
const trimmed = value?.trim();
|
|
143
|
+
return trimmed || undefined;
|
|
144
|
+
}
|