FN-6589: add structured ask-question chat tool
Add a native chat-agent tool for presenting structured questions to dashboard users. - Register `fn_ask_question` for dashboard chat sessions with prompt guidance to wait for user replies. - Reuse the existing structured question card parser by recognizing the new tool name. - Validate ask-question parameters in engine tooling and cover chat/parser behavior with tests. - Document the dashboard chat question flow and add a patch changeset. Files changed: .changeset/fn-6589-chat-ask-question.md | 5 ++ docs/dashboard-guide.md | 3 +- .../utils/__tests__/parseQuestionToolCall.test.ts | 19 +++++ .../dashboard/app/utils/parseQuestionToolCall.ts | 3 +- .../dashboard/src/__tests__/chat-manager.test.ts | 10 ++- packages/dashboard/src/chat.ts | 10 ++- .../src/__tests__/agent-tools-ask-question.test.ts | 51 ++++++++++++ packages/engine/src/agent-tools.ts | 94 ++++++++++++++++++++++ packages/engine/src/index.ts | 2 + 9 files changed, 190 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-6589 Fusion-Task-Lineage: 3f416010-ed2f-40b3-a899-7a9f4a6ed265
This commit is contained in:
5
.changeset/fn-6589-chat-ask-question.md
Normal file
5
.changeset/fn-6589-chat-ask-question.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add a Fusion-native `fn_ask_question` tool for dashboard chat agents so structured questions render in the existing chat response card and answers return through the next chat message.
|
||||
@@ -247,7 +247,8 @@ Chat view provides project-scoped conversations with agents.
|
||||
- Direct chat sessions can be renamed from the desktop conversation context menu and from the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
|
||||
- On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters.
|
||||
- Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged.
|
||||
- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only.
|
||||
<!-- FNXC:ChatAskQuestion 2026-06-17-16:35: Dashboard chat agents have a Fusion-native `fn_ask_question` tool, so the documented question-card behavior must cover both provider-native question tools and Fusion's first-party tool. -->
|
||||
- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card recognizes provider-native question tools and Fusion's `fn_ask_question`, supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only.
|
||||
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately.
|
||||
- Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools.
|
||||
- Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model.
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("parseQuestionToolCall", () => {
|
||||
it("recognizes question tool names case-insensitively", () => {
|
||||
expect(isQuestionToolName("AskUserQuestion")).toBe(true);
|
||||
expect(isQuestionToolName("ASK_USER")).toBe(true);
|
||||
expect(isQuestionToolName("fn_ask_question")).toBe(true);
|
||||
expect(isQuestionToolName("grep")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -57,6 +58,24 @@ describe("parseQuestionToolCall", () => {
|
||||
expect(parsed?.questions[0]?.id).toBe("q-0");
|
||||
});
|
||||
|
||||
it("normalizes fn_ask_question across all supported question types", () => {
|
||||
const parsed = parseQuestionToolCall(toolCall("fn_ask_question", {
|
||||
questions: [
|
||||
{ question: "Pick one", type: "single_select", options: [{ label: "Alpha" }] },
|
||||
{ question: "Pick many", type: "multi_select", options: [{ label: "Beta", description: "Second" }] },
|
||||
{ question: "Explain", type: "text", description: "Short answer is fine." },
|
||||
{ question: "Proceed?", type: "confirm" },
|
||||
],
|
||||
}));
|
||||
|
||||
expect(parsed?.questions).toEqual([
|
||||
expect.objectContaining({ id: "q-0", type: "single_select", question: "Pick one", options: [{ id: "opt-0", label: "Alpha", description: undefined }] }),
|
||||
expect.objectContaining({ id: "q-1", type: "multi_select", question: "Pick many", multiSelect: true, options: [{ id: "opt-0", label: "Beta", description: "Second" }] }),
|
||||
expect.objectContaining({ id: "q-2", type: "text", question: "Explain", description: "Short answer is fine." }),
|
||||
expect.objectContaining({ id: "q-3", type: "confirm", question: "Proceed?" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back for malformed, empty option select, and non-question tools", () => {
|
||||
expect(parseQuestionToolCall(toolCall("ask_user"))).toBeNull();
|
||||
expect(parseQuestionToolCall(toolCall("ask_user", { question: "" }))).toBeNull();
|
||||
|
||||
@@ -8,6 +8,7 @@ export const QUESTION_TOOL_NAMES = [
|
||||
"request_user_input",
|
||||
"elicit",
|
||||
"ask_question",
|
||||
"fn_ask_question",
|
||||
] as const;
|
||||
|
||||
const QUESTION_TOOL_NAME_SET = new Set(QUESTION_TOOL_NAMES.map((name) => name.toLowerCase()));
|
||||
@@ -37,7 +38,7 @@ export type ChatQuestionAnswers = Record<string, ChatQuestionAnswerValue>;
|
||||
|
||||
/**
|
||||
* FNXC:ChatQuestionResponse 2026-06-16-19:18:
|
||||
* Chat question tools from multiple agent CLIs must render as structured response controls in both ChatView and QuickChatFAB instead of exposing raw JSON in generic tool-call details.
|
||||
* Chat question tools from multiple agent CLIs and Fusion's native `fn_ask_question` tool must render as structured response controls in both ChatView and QuickChatFAB instead of exposing raw JSON in generic tool-call details.
|
||||
* Keep schema normalization centralized so both chat surfaces recognize the same question tools, synthesize stable ids, and fall back safely when args are malformed.
|
||||
*/
|
||||
export function isQuestionToolName(name: string): boolean {
|
||||
|
||||
@@ -920,6 +920,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
}));
|
||||
expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
customTools: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "fn_ask_question" }),
|
||||
expect.objectContaining({ name: "fn_send_message" }),
|
||||
expect.objectContaining({ name: "fn_read_messages" }),
|
||||
]),
|
||||
@@ -980,7 +981,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not inject mailbox tools for non-agent chat sessions", async () => {
|
||||
it("injects ask-question but not mailbox tools for non-agent chat sessions", async () => {
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-001",
|
||||
agentId: null,
|
||||
@@ -1008,9 +1009,10 @@ describe("ChatManager.sendMessage", () => {
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
expect(createResolvedSession).toHaveBeenCalledWith(expect.not.objectContaining({
|
||||
customTools: expect.anything(),
|
||||
}));
|
||||
const customTools = createResolvedSession.mock.calls[0]?.[0]?.customTools ?? [];
|
||||
expect(customTools.map((tool: { name: string }) => tool.name)).toContain("fn_ask_question");
|
||||
expect(customTools.map((tool: { name: string }) => tool.name)).not.toContain("fn_send_message");
|
||||
expect(customTools.map((tool: { name: string }) => tool.name)).not.toContain("fn_read_messages");
|
||||
});
|
||||
|
||||
it("uses the assigned built-in pi agent model when the chat session has no explicit model override", async () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
buildSessionSkillContextSync,
|
||||
createSendMessageTool,
|
||||
createReadMessagesTool,
|
||||
createAskQuestionTool,
|
||||
createWorkflowAuthoringTools,
|
||||
} from "@fusion/engine";
|
||||
import * as engineModule from "@fusion/engine";
|
||||
@@ -133,6 +134,12 @@ export const CHAT_SYSTEM_PROMPT = `You are a helpful AI assistant integrated int
|
||||
|
||||
export const CHAT_AGENT_MESSAGE_ROUTING_GUIDANCE = `## Messaging Semantics\n\nYour chat reply is the primary response to the user. Do not also call \`fn_send_message\` with the same content just to mirror your chat response into mailbox.\n\nUse \`fn_send_message\` only when either (a) the user explicitly asks for mailbox/inbox/notification delivery (for example: "send me this in mail", "ntfy me when…", or "leave me a note in my inbox"), or (b) you are sending a genuinely longer follow-up that did not fit in a short chat reply. In either case, send with \`type: "agent-to-user"\` and target the dashboard user alias (\`to_id: "dashboard"\` is preferred), and ensure the mailbox message is additive rather than a duplicate of the chat reply. Never route that as a user/CLI → agent message.`;
|
||||
|
||||
/**
|
||||
* FNXC:ChatAskQuestion 2026-06-17-13:17:
|
||||
* Only the dashboard chat lane registers `fn_ask_question`, so append this guidance during sendMessage prompt assembly instead of baking it into room-responder prompts that do not receive the tool.
|
||||
*/
|
||||
export const CHAT_ASK_QUESTION_GUIDANCE = `## Asking the User\n\nWhen you need structured input, call \`fn_ask_question\` with one or more questions, then stop and wait for the user's next chat message.`;
|
||||
|
||||
/** Rate limiting window in milliseconds (1 minute) */
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
@@ -1618,6 +1625,7 @@ export class ChatManager {
|
||||
diagnostics.warn(`Failed to build enriched system prompt for ${agent.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
systemPrompt = `${systemPrompt}\n\n${CHAT_ASK_QUESTION_GUIDANCE}`;
|
||||
|
||||
if (agent) {
|
||||
const runtimeModel = extractRuntimeModel(agent.runtimeConfig);
|
||||
@@ -1715,7 +1723,7 @@ export class ChatManager {
|
||||
? createWorkflowAuthoringTools(this.taskStore, "", { stripApprovalFlags: true })
|
||||
: [];
|
||||
|
||||
const customTools = [...messagingTools, ...workflowTools];
|
||||
const customTools = [createAskQuestionTool(), ...messagingTools, ...workflowTools];
|
||||
|
||||
const sessionOptions = {
|
||||
cwd: this.rootDir,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAskQuestionTool } from "../agent-tools.js";
|
||||
|
||||
describe("createAskQuestionTool", () => {
|
||||
async function execute(params: Parameters<ReturnType<typeof createAskQuestionTool>["execute"]>[1]) {
|
||||
const tool = createAskQuestionTool();
|
||||
return tool.execute("call-1", params, undefined as never, undefined as never, undefined as never);
|
||||
}
|
||||
|
||||
it("creates the fn_ask_question tool", () => {
|
||||
const tool = createAskQuestionTool();
|
||||
|
||||
expect(tool.name).toBe("fn_ask_question");
|
||||
expect(tool.label).toBe("Ask User Question");
|
||||
});
|
||||
|
||||
it("accepts valid single-question and multi-question payloads and tells the agent to wait", async () => {
|
||||
const single = await execute({
|
||||
questions: [{ question: "Which path should I take?", type: "single_select", options: [{ label: "A" }] }],
|
||||
});
|
||||
expect(single.isError).not.toBe(true);
|
||||
expect(single.details).toEqual({ questionCount: 1 });
|
||||
expect(single.content[0]?.type === "text" ? single.content[0].text : "").toContain("Stop and wait");
|
||||
|
||||
const multi = await execute({
|
||||
questions: [
|
||||
{ question: "Explain the goal", type: "text" },
|
||||
{ question: "Proceed?", type: "confirm" },
|
||||
],
|
||||
});
|
||||
expect(multi.isError).not.toBe(true);
|
||||
expect(multi.details).toEqual({ questionCount: 2 });
|
||||
expect(multi.content[0]?.type === "text" ? multi.content[0].text : "").toContain("next turn");
|
||||
});
|
||||
|
||||
it("accepts text and confirm questions without options", async () => {
|
||||
const text = await execute({ questions: [{ question: "What should I call it?", type: "text" }] });
|
||||
const confirm = await execute({ questions: [{ question: "Should I continue?", type: "confirm" }] });
|
||||
|
||||
expect(text.isError).not.toBe(true);
|
||||
expect(confirm.isError).not.toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty question lists, blank question text, and optionless select questions", async () => {
|
||||
await expect(execute({ questions: [] })).resolves.toMatchObject({ isError: true });
|
||||
await expect(execute({ questions: [{ question: " ", type: "text" }] })).resolves.toMatchObject({ isError: true });
|
||||
await expect(execute({ questions: [{ question: "Pick one", type: "single_select" }] })).resolves.toMatchObject({ isError: true });
|
||||
await expect(execute({ questions: [{ question: "Pick many", type: "multi_select", options: [] }] })).resolves.toMatchObject({ isError: true });
|
||||
await expect(execute({ questions: [{ question: "Pick many", multiSelect: true }] })).resolves.toMatchObject({ isError: true });
|
||||
});
|
||||
});
|
||||
@@ -301,6 +301,40 @@ export const postRoomMessageParams = Type.Object({
|
||||
mentions: Type.Optional(Type.Array(Type.String(), { description: "Optional agent IDs to mention in the room message" })),
|
||||
});
|
||||
|
||||
export const askQuestionParams = Type.Object({
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
question: Type.String({
|
||||
description: "The question text shown to the user. Required and must be specific enough to answer.",
|
||||
}),
|
||||
header: Type.Optional(Type.String({
|
||||
description: "Optional short heading for the question card, such as 'Decision needed'.",
|
||||
})),
|
||||
description: Type.Optional(Type.String({
|
||||
description: "Optional helper text explaining why the answer is needed or how it will be used.",
|
||||
})),
|
||||
options: Type.Optional(Type.Array(Type.Object({
|
||||
label: Type.String({ description: "Visible option label the user can choose." }),
|
||||
description: Type.Optional(Type.String({ description: "Optional explanatory text for this option." })),
|
||||
}), {
|
||||
description: "Options for single_select, multi_select, or confirm questions. Select questions require at least one option.",
|
||||
})),
|
||||
multiSelect: Type.Optional(Type.Boolean({
|
||||
description: "Set true when the user may choose multiple options. Prefer type='multi_select' for clarity.",
|
||||
})),
|
||||
type: Type.Optional(Type.Union([
|
||||
Type.Literal("text"),
|
||||
Type.Literal("single_select"),
|
||||
Type.Literal("multi_select"),
|
||||
Type.Literal("confirm"),
|
||||
], {
|
||||
description: "Question input type: free text, single option, multiple options, or yes/no confirmation.",
|
||||
})),
|
||||
}),
|
||||
{ description: "One or more structured questions to present to the user." },
|
||||
),
|
||||
});
|
||||
|
||||
export const memorySearchParams = Type.Object({
|
||||
query: Type.String({ description: "Search terms for durable project memory. Use focused keywords, not a full prompt." }),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum snippets to return (default: 5, max: 20)" })),
|
||||
@@ -2871,6 +2905,66 @@ export function createDelegateTaskTool(
|
||||
};
|
||||
}
|
||||
|
||||
type AskQuestionInput = Static<typeof askQuestionParams>;
|
||||
|
||||
function askQuestionError(message: string) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ChatAskQuestion 2026-06-17-13:08:
|
||||
* Dashboard chat agents need a provider-agnostic `fn_ask_question` tool that emits the FN-6501 structured question payload, renders through the existing chat question UI, and receives the answer through the normal next user message instead of a blocking tool response.
|
||||
*
|
||||
* Create a `fn_ask_question` tool that asks the dashboard user structured questions.
|
||||
*
|
||||
* @returns ToolDefinition for the `fn_ask_question` tool
|
||||
*/
|
||||
export function createAskQuestionTool(): ToolDefinition {
|
||||
return {
|
||||
name: "fn_ask_question",
|
||||
label: "Ask User Question",
|
||||
description:
|
||||
"Ask the user a structured question (single-select, multi-select, free-text, or yes/no confirm). " +
|
||||
"The question renders as an interactive card in chat. After calling this tool, end the turn and wait; " +
|
||||
"the user's answer arrives as the next message.",
|
||||
parameters: askQuestionParams,
|
||||
execute: async (_id: string, params: AskQuestionInput) => {
|
||||
if (!Array.isArray(params.questions) || params.questions.length === 0) {
|
||||
return askQuestionError("questions must contain at least one question");
|
||||
}
|
||||
|
||||
for (const [index, question] of params.questions.entries()) {
|
||||
const questionText = typeof question.question === "string" ? question.question.trim() : "";
|
||||
if (!questionText) {
|
||||
return askQuestionError(`questions[${index}].question must be a non-empty string`);
|
||||
}
|
||||
|
||||
const optionCount = Array.isArray(question.options)
|
||||
? question.options.filter((option) => typeof option.label === "string" && option.label.trim().length > 0).length
|
||||
: 0;
|
||||
const requiresOptions = question.type === "single_select"
|
||||
|| question.type === "multi_select"
|
||||
|| question.multiSelect === true;
|
||||
if (requiresOptions && optionCount === 0) {
|
||||
return askQuestionError(`questions[${index}] select questions must include at least one option`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: "Question presented to the user. Stop and wait for their reply on the next turn.",
|
||||
}],
|
||||
details: { questionCount: params.questions.length },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_send_message` tool that sends a message to another agent or user.
|
||||
*
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
createTaskLogTool,
|
||||
createSendMessageTool,
|
||||
createReadMessagesTool,
|
||||
createAskQuestionTool,
|
||||
createWorkflowListTool,
|
||||
createWorkflowGetTool,
|
||||
createWorkflowSelectTool,
|
||||
@@ -20,6 +21,7 @@ export {
|
||||
taskDocumentReadParams,
|
||||
taskDocumentWriteParams,
|
||||
taskLogParams,
|
||||
askQuestionParams,
|
||||
workflowListParams,
|
||||
workflowSelectParams,
|
||||
executeApprovedAgentProvisioning,
|
||||
|
||||
Reference in New Issue
Block a user