feat(platform): add generic ask_question copilot tool

Add a generic `ask_question` tool that lets the copilot ask the user
clarifying questions via a dedicated UI card instead of plain text
(which gets collapsed into hidden reasoning). Reuses the existing
`ClarificationNeededResponse` model and `ClarificationQuestionsCard`
component.

Backend:
- New `AskQuestionTool` in `copilot/tools/ask_question.py`
- Registered in `TOOL_REGISTRY` and `ToolName` permissions literal
- Updated `agent_generation_guide.md` to reference `ask_question`

Frontend:
- Added `tool-ask_question` to `CUSTOM_TOOL_TYPES` (prevents collapse)
- New `AskQuestion/` renderer reusing `ClarificationQuestionsCard`
- Registered in `MessagePartRenderer` switch
This commit is contained in:
Zamil Majdy
2026-04-02 13:40:09 +02:00
parent 11b846dd49
commit 9678c4a86d
8 changed files with 250 additions and 1 deletions

View File

@@ -66,6 +66,7 @@ from pydantic import BaseModel, PrivateAttr
ToolName = Literal[
# Platform tools (must match keys in TOOL_REGISTRY)
"add_understanding",
"ask_question",
"bash_exec",
"browser_act",
"browser_navigate",

View File

@@ -10,7 +10,7 @@ Before starting the workflow below, check whether the user's goal is
or trigger. If so:
1. Call `find_block` with a query targeting the ambiguous dimension to
discover what the platform actually supports.
2. Ask the user **one concrete question** grounded in the discovered
2. Call `ask_question` with a concrete question listing the discovered
options (e.g. "The platform supports Gmail, Slack, and Google Docs —
which should the agent use for delivery?").
3. **Wait for the user's answer** before proceeding.

View File

@@ -10,6 +10,7 @@ from backend.copilot.tracking import track_tool_called
from .add_understanding import AddUnderstandingTool
from .agent_browser import BrowserActTool, BrowserNavigateTool, BrowserScreenshotTool
from .agent_output import AgentOutputTool
from .ask_question import AskQuestionTool
from .base import BaseTool
from .bash_exec import BashExecTool
from .connect_integration import ConnectIntegrationTool
@@ -55,6 +56,7 @@ logger = logging.getLogger(__name__)
# Single source of truth for all tools
TOOL_REGISTRY: dict[str, BaseTool] = {
"add_understanding": AddUnderstandingTool(),
"ask_question": AskQuestionTool(),
"create_agent": CreateAgentTool(),
"customize_agent": CustomizeAgentTool(),
"edit_agent": EditAgentTool(),

View File

@@ -0,0 +1,89 @@
"""AskQuestionTool - Ask the user a clarifying question before proceeding."""
import logging
from typing import Any
from backend.copilot.model import ChatSession
from .base import BaseTool
from .models import ClarificationNeededResponse, ClarifyingQuestion, ToolResponseBase
logger = logging.getLogger(__name__)
class AskQuestionTool(BaseTool):
"""Ask the user a clarifying question and wait for their answer.
Use this tool when the user's request is ambiguous and you need more
information before proceeding. Call find_block or other discovery tools
first to ground your question in real platform options, then call this
tool with a concrete question listing those options.
"""
@property
def name(self) -> str:
return "ask_question"
@property
def description(self) -> str:
return (
"Ask the user a clarifying question. Use when the request is "
"ambiguous and you need to confirm intent, choose between options, "
"or gather missing details before proceeding."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": (
"The concrete question to ask the user. Should list "
"real options when applicable."
),
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": (
"Options for the user to choose from "
"(e.g. ['Email', 'Slack', 'Google Docs'])."
),
},
"keyword": {
"type": "string",
"description": "Short label identifying what the question is about.",
},
},
"required": ["question"],
}
@property
def requires_auth(self) -> bool:
return False
async def _execute(
self,
user_id: str | None,
session: ChatSession,
**kwargs: Any,
) -> ToolResponseBase:
del user_id # unused; required by BaseTool contract
question: str = kwargs.get("question", "")
options: list[str] = kwargs.get("options", [])
keyword: str = kwargs.get("keyword", "")
session_id = session.session_id if session else None
example = ", ".join(options) if options else None
clarifying_question = ClarifyingQuestion(
question=question,
keyword=keyword,
example=example,
)
return ClarificationNeededResponse(
message=question,
session_id=session_id,
questions=[clarifying_question],
)

View File

@@ -3,6 +3,7 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { ExclamationMarkIcon } from "@phosphor-icons/react";
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { useState } from "react";
import { AskQuestionTool } from "../../../tools/AskQuestion/AskQuestion";
import { ConnectIntegrationTool } from "../../../tools/ConnectIntegrationTool/ConnectIntegrationTool";
import { CreateAgentTool } from "../../../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../../../tools/EditAgent/EditAgent";
@@ -129,6 +130,8 @@ export function MessagePartRenderer({
</MessageResponse>
);
}
case "tool-ask_question":
return <AskQuestionTool key={key} part={part as ToolUIPart} />;
case "tool-find_block":
return <FindBlocksTool key={key} part={part as ToolUIPart} />;
case "tool-find_agent":

View File

@@ -13,6 +13,7 @@ export type RenderSegment =
| { kind: "collapsed-group"; parts: ToolUIPart[] };
const CUSTOM_TOOL_TYPES = new Set([
"tool-ask_question",
"tool-find_block",
"tool-find_agent",
"tool-find_library_agent",

View File

@@ -0,0 +1,67 @@
"use client";
import { ChatTeardropDotsIcon, WarningCircleIcon } from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { ClarificationQuestionsCard } from "../CreateAgent/components/ClarificationQuestionsCard";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { normalizeClarifyingQuestions } from "../clarifying-questions";
import {
getAnimationText,
getAskQuestionOutput,
isClarificationOutput,
isErrorOutput,
} from "./helpers";
interface Props {
part: ToolUIPart;
}
export function AskQuestionTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const isError = part.state === "output-error";
const output = getAskQuestionOutput(part);
function handleAnswers(answers: Record<string, string>) {
if (!output || !isClarificationOutput(output)) return;
const questions = output.questions ?? [];
const message = questions
.map((q) => {
const answer = answers[q.keyword] || "";
return `> ${q.question}\n\n${answer}`;
})
.join("\n\n");
onSend(`**Here are my answers:**\n\n${message}\n\nPlease proceed.`);
}
if (output && isClarificationOutput(output)) {
return (
<ClarificationQuestionsCard
questions={normalizeClarifyingQuestions(output.questions ?? [])}
message={output.message}
onSubmitAnswers={handleAnswers}
/>
);
}
return (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
{isError || (output && isErrorOutput(output)) ? (
<WarningCircleIcon size={16} className="text-red-500" />
) : isStreaming ? (
<ChatTeardropDotsIcon size={16} className="animate-pulse" />
) : (
<ChatTeardropDotsIcon size={16} />
)}
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import type { ToolUIPart } from "ai";
interface ClarifyingQuestionPayload {
question: string;
keyword: string;
example?: string;
}
export interface AskQuestionOutput {
type: string;
message: string;
questions: ClarifyingQuestionPayload[];
session_id?: string;
}
interface ErrorOutput {
type: "error";
message: string;
error?: string;
}
export type AskQuestionToolOutput = AskQuestionOutput | ErrorOutput;
function parseOutput(output: unknown): AskQuestionToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
try {
return parseOutput(JSON.parse(output) as unknown);
} catch {
return null;
}
}
if (typeof output === "object" && output !== null) {
const obj = output as Record<string, unknown>;
if (
obj.type === ResponseType.agent_builder_clarification_needed ||
"questions" in obj
) {
return obj as unknown as AskQuestionOutput;
}
if (obj.type === "error" || "error" in obj) {
return obj as unknown as ErrorOutput;
}
}
return null;
}
export function getAskQuestionOutput(
part: ToolUIPart,
): AskQuestionToolOutput | null {
return parseOutput(part.output);
}
export function isClarificationOutput(
output: AskQuestionToolOutput,
): output is AskQuestionOutput {
return (
output.type === ResponseType.agent_builder_clarification_needed ||
"questions" in output
);
}
export function isErrorOutput(
output: AskQuestionToolOutput,
): output is ErrorOutput {
return output.type === "error";
}
export function getAnimationText(part: ToolUIPart): string {
switch (part.state) {
case "input-streaming":
case "input-available":
return "Asking question...";
case "output-available": {
const output = parseOutput(part.output);
if (output && isClarificationOutput(output)) return "Needs your input";
if (output && isErrorOutput(output)) return "Failed to ask question";
return "Asking question...";
}
case "output-error":
return "Failed to ask question";
default:
return "Asking question...";
}
}