|
|
|
|
@@ -1,24 +1,21 @@
|
|
|
|
|
import { feature } from 'bun:bundle'
|
|
|
|
|
import * as React from 'react'
|
|
|
|
|
import {
|
|
|
|
|
getAllowedChannels,
|
|
|
|
|
getQuestionPreviewFormat,
|
|
|
|
|
} from 'src/bootstrap/state.js'
|
|
|
|
|
import { MessageResponse } from 'src/components/MessageResponse.js'
|
|
|
|
|
import { BLACK_CIRCLE } from 'src/constants/figures.js'
|
|
|
|
|
import { getModeColor } from 'src/utils/permissions/PermissionMode.js'
|
|
|
|
|
import { z } from 'zod/v4'
|
|
|
|
|
import { Box, Text } from '@anthropic/ink'
|
|
|
|
|
import type { Tool } from 'src/Tool.js'
|
|
|
|
|
import { buildTool, type ToolDef } from 'src/Tool.js'
|
|
|
|
|
import { lazySchema } from 'src/utils/lazySchema.js'
|
|
|
|
|
import { feature } from 'bun:bundle';
|
|
|
|
|
import * as React from 'react';
|
|
|
|
|
import { getAllowedChannels, getQuestionPreviewFormat } from 'src/bootstrap/state.js';
|
|
|
|
|
import { MessageResponse } from 'src/components/MessageResponse.js';
|
|
|
|
|
import { BLACK_CIRCLE } from 'src/constants/figures.js';
|
|
|
|
|
import { getModeColor } from 'src/utils/permissions/PermissionMode.js';
|
|
|
|
|
import { z } from 'zod/v4';
|
|
|
|
|
import { Box, Text } from '@anthropic/ink';
|
|
|
|
|
import type { Tool } from 'src/Tool.js';
|
|
|
|
|
import { buildTool, type ToolDef } from 'src/Tool.js';
|
|
|
|
|
import { lazySchema } from 'src/utils/lazySchema.js';
|
|
|
|
|
import {
|
|
|
|
|
ASK_USER_QUESTION_TOOL_CHIP_WIDTH,
|
|
|
|
|
ASK_USER_QUESTION_TOOL_NAME,
|
|
|
|
|
ASK_USER_QUESTION_TOOL_PROMPT,
|
|
|
|
|
DESCRIPTION,
|
|
|
|
|
PREVIEW_FEATURE_PROMPT,
|
|
|
|
|
} from './prompt.js'
|
|
|
|
|
} from './prompt.js';
|
|
|
|
|
|
|
|
|
|
const questionOptionSchema = lazySchema(() =>
|
|
|
|
|
z.object({
|
|
|
|
|
@@ -39,7 +36,7 @@ const questionOptionSchema = lazySchema(() =>
|
|
|
|
|
'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.',
|
|
|
|
|
),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const questionSchema = lazySchema(() =>
|
|
|
|
|
z.object({
|
|
|
|
|
@@ -67,55 +64,44 @@ const questionSchema = lazySchema(() =>
|
|
|
|
|
'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.',
|
|
|
|
|
),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const annotationsSchema = lazySchema(() => {
|
|
|
|
|
const annotationSchema = z.object({
|
|
|
|
|
preview: z
|
|
|
|
|
.string()
|
|
|
|
|
.optional()
|
|
|
|
|
.describe(
|
|
|
|
|
'The preview content of the selected option, if the question used previews.',
|
|
|
|
|
),
|
|
|
|
|
notes: z
|
|
|
|
|
.string()
|
|
|
|
|
.optional()
|
|
|
|
|
.describe('Free-text notes the user added to their selection.'),
|
|
|
|
|
})
|
|
|
|
|
.describe('The preview content of the selected option, if the question used previews.'),
|
|
|
|
|
notes: z.string().optional().describe('Free-text notes the user added to their selection.'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return z
|
|
|
|
|
.record(z.string(), annotationSchema)
|
|
|
|
|
.optional()
|
|
|
|
|
.describe(
|
|
|
|
|
'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.',
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const UNIQUENESS_REFINE = {
|
|
|
|
|
check: (data: {
|
|
|
|
|
questions: { question: string; options: { label: string }[] }[]
|
|
|
|
|
}) => {
|
|
|
|
|
const questions = data.questions.map(q => q.question)
|
|
|
|
|
check: (data: { questions: { question: string; options: { label: string }[] }[] }) => {
|
|
|
|
|
const questions = data.questions.map(q => q.question);
|
|
|
|
|
if (questions.length !== new Set(questions).size) {
|
|
|
|
|
return false
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
for (const question of data.questions) {
|
|
|
|
|
const labels = question.options.map(opt => opt.label)
|
|
|
|
|
const labels = question.options.map(opt => opt.label);
|
|
|
|
|
if (labels.length !== new Set(labels).size) {
|
|
|
|
|
return false
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
message:
|
|
|
|
|
'Question texts must be unique, option labels must be unique within each question',
|
|
|
|
|
} as const
|
|
|
|
|
message: 'Question texts must be unique, option labels must be unique within each question',
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
const commonFields = lazySchema(() => ({
|
|
|
|
|
answers: z
|
|
|
|
|
.record(z.string(), z.string())
|
|
|
|
|
.optional()
|
|
|
|
|
.describe('User answers collected by the permission component'),
|
|
|
|
|
answers: z.record(z.string(), z.string()).optional().describe('User answers collected by the permission component'),
|
|
|
|
|
annotations: annotationsSchema(),
|
|
|
|
|
metadata: z
|
|
|
|
|
.object({
|
|
|
|
|
@@ -127,32 +113,24 @@ const commonFields = lazySchema(() => ({
|
|
|
|
|
),
|
|
|
|
|
})
|
|
|
|
|
.optional()
|
|
|
|
|
.describe(
|
|
|
|
|
'Optional metadata for tracking and analytics purposes. Not displayed to user.',
|
|
|
|
|
),
|
|
|
|
|
}))
|
|
|
|
|
.describe('Optional metadata for tracking and analytics purposes. Not displayed to user.'),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const inputSchema = lazySchema(() =>
|
|
|
|
|
z
|
|
|
|
|
.strictObject({
|
|
|
|
|
questions: z
|
|
|
|
|
.array(questionSchema())
|
|
|
|
|
.min(1)
|
|
|
|
|
.max(4)
|
|
|
|
|
.describe('Questions to ask the user (1-4 questions)'),
|
|
|
|
|
questions: z.array(questionSchema()).min(1).max(4).describe('Questions to ask the user (1-4 questions)'),
|
|
|
|
|
...commonFields(),
|
|
|
|
|
})
|
|
|
|
|
.refine(UNIQUENESS_REFINE.check, {
|
|
|
|
|
message: UNIQUENESS_REFINE.message,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
type InputSchema = ReturnType<typeof inputSchema>
|
|
|
|
|
);
|
|
|
|
|
type InputSchema = ReturnType<typeof inputSchema>;
|
|
|
|
|
|
|
|
|
|
const outputSchema = lazySchema(() =>
|
|
|
|
|
z.object({
|
|
|
|
|
questions: z
|
|
|
|
|
.array(questionSchema())
|
|
|
|
|
.describe('The questions that were asked'),
|
|
|
|
|
questions: z.array(questionSchema()).describe('The questions that were asked'),
|
|
|
|
|
answers: z
|
|
|
|
|
.record(z.string(), z.string())
|
|
|
|
|
.describe(
|
|
|
|
|
@@ -160,23 +138,19 @@ const outputSchema = lazySchema(() =>
|
|
|
|
|
),
|
|
|
|
|
annotations: annotationsSchema(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
type OutputSchema = ReturnType<typeof outputSchema>
|
|
|
|
|
);
|
|
|
|
|
type OutputSchema = ReturnType<typeof outputSchema>;
|
|
|
|
|
|
|
|
|
|
// SDK schemas are identical to internal schemas now that `preview` and
|
|
|
|
|
// `annotations` are public (configurable via `toolConfig.askUserQuestion`).
|
|
|
|
|
export const _sdkInputSchema = inputSchema
|
|
|
|
|
export const _sdkOutputSchema = outputSchema
|
|
|
|
|
export const _sdkInputSchema = inputSchema;
|
|
|
|
|
export const _sdkOutputSchema = outputSchema;
|
|
|
|
|
|
|
|
|
|
export type Question = z.infer<ReturnType<typeof questionSchema>>
|
|
|
|
|
export type QuestionOption = z.infer<ReturnType<typeof questionOptionSchema>>
|
|
|
|
|
export type Output = z.infer<OutputSchema>
|
|
|
|
|
export type Question = z.infer<ReturnType<typeof questionSchema>>;
|
|
|
|
|
export type QuestionOption = z.infer<ReturnType<typeof questionOptionSchema>>;
|
|
|
|
|
export type Output = z.infer<OutputSchema>;
|
|
|
|
|
|
|
|
|
|
function AskUserQuestionResultMessage({
|
|
|
|
|
answers,
|
|
|
|
|
}: {
|
|
|
|
|
answers: Output['answers']
|
|
|
|
|
}): React.ReactNode {
|
|
|
|
|
function AskUserQuestionResultMessage({ answers }: { answers: Output['answers'] }): React.ReactNode {
|
|
|
|
|
return (
|
|
|
|
|
<Box flexDirection="column" marginTop={1}>
|
|
|
|
|
<Box flexDirection="row">
|
|
|
|
|
@@ -193,7 +167,7 @@ function AskUserQuestionResultMessage({
|
|
|
|
|
</Box>
|
|
|
|
|
</MessageResponse>
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
|
|
|
|
|
@@ -202,25 +176,25 @@ export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
|
|
|
|
|
maxResultSizeChars: 100_000,
|
|
|
|
|
shouldDefer: true,
|
|
|
|
|
async description() {
|
|
|
|
|
return DESCRIPTION
|
|
|
|
|
return DESCRIPTION;
|
|
|
|
|
},
|
|
|
|
|
async prompt() {
|
|
|
|
|
const format = getQuestionPreviewFormat()
|
|
|
|
|
const format = getQuestionPreviewFormat();
|
|
|
|
|
if (format === undefined) {
|
|
|
|
|
// SDK consumer that hasn't opted into a preview format — omit preview
|
|
|
|
|
// guidance (they may not render the field at all).
|
|
|
|
|
return ASK_USER_QUESTION_TOOL_PROMPT
|
|
|
|
|
return ASK_USER_QUESTION_TOOL_PROMPT;
|
|
|
|
|
}
|
|
|
|
|
return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]
|
|
|
|
|
return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format];
|
|
|
|
|
},
|
|
|
|
|
get inputSchema(): InputSchema {
|
|
|
|
|
return inputSchema()
|
|
|
|
|
return inputSchema();
|
|
|
|
|
},
|
|
|
|
|
get outputSchema(): OutputSchema {
|
|
|
|
|
return outputSchema()
|
|
|
|
|
return outputSchema();
|
|
|
|
|
},
|
|
|
|
|
userFacingName() {
|
|
|
|
|
return ''
|
|
|
|
|
return '';
|
|
|
|
|
},
|
|
|
|
|
isEnabled() {
|
|
|
|
|
// When --channels is active the user is likely on Telegram/Discord, not
|
|
|
|
|
@@ -228,59 +202,56 @@ export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
|
|
|
|
|
// the keyboard. Channel permission relay already skips
|
|
|
|
|
// requiresUserInteraction() tools (interactiveHandler.ts) so there's
|
|
|
|
|
// no alternate approval path.
|
|
|
|
|
if (
|
|
|
|
|
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
|
|
|
|
|
getAllowedChannels().length > 0
|
|
|
|
|
) {
|
|
|
|
|
return false
|
|
|
|
|
if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
isConcurrencySafe() {
|
|
|
|
|
return true
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
isReadOnly() {
|
|
|
|
|
return true
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
toAutoClassifierInput(input) {
|
|
|
|
|
return input.questions.map(q => q.question).join(' | ')
|
|
|
|
|
return input.questions.map(q => q.question).join(' | ');
|
|
|
|
|
},
|
|
|
|
|
requiresUserInteraction() {
|
|
|
|
|
return true
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
async validateInput({ questions }) {
|
|
|
|
|
if (getQuestionPreviewFormat() !== 'html') {
|
|
|
|
|
return { result: true }
|
|
|
|
|
return { result: true };
|
|
|
|
|
}
|
|
|
|
|
for (const q of questions) {
|
|
|
|
|
for (const opt of q.options) {
|
|
|
|
|
const err = validateHtmlPreview(opt.preview)
|
|
|
|
|
const err = validateHtmlPreview(opt.preview);
|
|
|
|
|
if (err) {
|
|
|
|
|
return {
|
|
|
|
|
result: false,
|
|
|
|
|
message: `Option "${opt.label}" in question "${q.question}": ${err}`,
|
|
|
|
|
errorCode: 1,
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { result: true }
|
|
|
|
|
return { result: true };
|
|
|
|
|
},
|
|
|
|
|
async checkPermissions(input) {
|
|
|
|
|
return {
|
|
|
|
|
behavior: 'ask' as const,
|
|
|
|
|
message: 'Answer questions?',
|
|
|
|
|
updatedInput: input,
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
renderToolUseMessage() {
|
|
|
|
|
return null
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
renderToolUseProgressMessage() {
|
|
|
|
|
return null
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
renderToolResultMessage({ answers }, _toolUseID) {
|
|
|
|
|
return <AskUserQuestionResultMessage answers={answers} />
|
|
|
|
|
return <AskUserQuestionResultMessage answers={answers} />;
|
|
|
|
|
},
|
|
|
|
|
renderToolUseRejectedMessage() {
|
|
|
|
|
return (
|
|
|
|
|
@@ -288,55 +259,55 @@ export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
|
|
|
|
|
<Text color={getModeColor('default')}>{BLACK_CIRCLE} </Text>
|
|
|
|
|
<Text>User declined to answer questions</Text>
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
renderToolUseErrorMessage() {
|
|
|
|
|
return null
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
async call({ questions, answers = {}, annotations }, _context) {
|
|
|
|
|
return {
|
|
|
|
|
data: { questions, answers, ...(annotations && { annotations }) },
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) {
|
|
|
|
|
const answersText = Object.entries(answers)
|
|
|
|
|
.map(([questionText, answer]) => {
|
|
|
|
|
const annotation = annotations?.[questionText]
|
|
|
|
|
const parts = [`"${questionText}"="${answer}"`]
|
|
|
|
|
const annotation = annotations?.[questionText];
|
|
|
|
|
const parts = [`"${questionText}"="${answer}"`];
|
|
|
|
|
if (annotation?.preview) {
|
|
|
|
|
parts.push(`selected preview:\n${annotation.preview}`)
|
|
|
|
|
parts.push(`selected preview:\n${annotation.preview}`);
|
|
|
|
|
}
|
|
|
|
|
if (annotation?.notes) {
|
|
|
|
|
parts.push(`user notes: ${annotation.notes}`)
|
|
|
|
|
parts.push(`user notes: ${annotation.notes}`);
|
|
|
|
|
}
|
|
|
|
|
return parts.join(' ')
|
|
|
|
|
return parts.join(' ');
|
|
|
|
|
})
|
|
|
|
|
.join(', ')
|
|
|
|
|
.join(', ');
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: 'tool_result',
|
|
|
|
|
content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`,
|
|
|
|
|
tool_use_id: toolUseID,
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
} satisfies ToolDef<InputSchema, Output>)
|
|
|
|
|
} satisfies ToolDef<InputSchema, Output>);
|
|
|
|
|
|
|
|
|
|
// Lightweight HTML fragment check. Not a parser — HTML5 parsers are
|
|
|
|
|
// error-recovering by spec and accept anything. We're checking model intent
|
|
|
|
|
// (did it emit HTML?) and catching the specific things we told it not to do.
|
|
|
|
|
function validateHtmlPreview(preview: string | undefined): string | null {
|
|
|
|
|
if (preview === undefined) return null
|
|
|
|
|
if (preview === undefined) return null;
|
|
|
|
|
if (/<\s*(html|body|!doctype)\b/i.test(preview)) {
|
|
|
|
|
return 'preview must be an HTML fragment, not a full document (no <html>, <body>, or <!DOCTYPE>)'
|
|
|
|
|
return 'preview must be an HTML fragment, not a full document (no <html>, <body>, or <!DOCTYPE>)';
|
|
|
|
|
}
|
|
|
|
|
// SDK consumers typically set this via innerHTML — disallow executable/style
|
|
|
|
|
// tags so a preview can't run code or restyle the host page. Inline event
|
|
|
|
|
// handlers (onclick etc.) are still possible; consumers should sanitize.
|
|
|
|
|
if (/<\s*(script|style)\b/i.test(preview)) {
|
|
|
|
|
return 'preview must not contain <script> or <style> tags. Use inline styles via the style attribute if needed.'
|
|
|
|
|
return 'preview must not contain <script> or <style> tags. Use inline styles via the style attribute if needed.';
|
|
|
|
|
}
|
|
|
|
|
if (!/<[a-z][^>]*>/i.test(preview)) {
|
|
|
|
|
return 'preview must contain HTML (previewFormat is set to "html"). Wrap content in a tag like <div> or <pre>.'
|
|
|
|
|
return 'preview must contain HTML (previewFormat is set to "html"). Wrap content in a tag like <div> or <pre>.';
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|