Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/agent/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const REVIEWER_RECURSION_LIMIT = 40;
import { getBundledSkill } from "../skills/defaults";
import { extractErrorMessage } from "../utils/errorMessage";
import { LangSmithTelemetry, type Telemetry } from "./telemetry";
import { createAskQuestionTool } from "./tools/askQuestion";
import { createExecuteJavaScriptTool } from "./tools/executeJavaScript";
import { createPluginApiExecTool } from "./tools/executePluginApi";
import { createFetchUrlTool } from "./tools/fetchUrl";
Expand Down Expand Up @@ -867,7 +868,10 @@ export class AgentManager {
* reading `toolsConfig[id].enabled` alone, which is only half the condition.
*/
isToolBound(agentCfg: AgentConfig, toolId: BuiltInToolId): boolean {
return this.attachedToolIds(agentCfg).has(toolId) && (agentCfg.toolsConfig[toolId]?.enabled ?? true);
return (
(this.attachedToolIds(agentCfg).has(toolId) || toolId === "ask_question") &&
(agentCfg.toolsConfig[toolId]?.enabled ?? true)
);
}

/**
Expand All @@ -884,7 +888,9 @@ export class AgentManager {
// walked a single time while building the whole tool list.
const attached = this.attachedToolIds(agentCfg);
const isToolEnabled = (toolId: BuiltInToolId): boolean => {
return attached.has(toolId) && (agentCfg.toolsConfig[toolId]?.enabled ?? true);
return (
(attached.has(toolId) || toolId === "ask_question") && (agentCfg.toolsConfig[toolId]?.enabled ?? true)
);
};

// Instantiate vision processor models for read_content.
Expand Down Expand Up @@ -952,6 +958,7 @@ export class AgentManager {
["fetch_url", () => createFetchUrlTool()],
["web_search", () => createWebSearchTool(agentCfg.id)],
["manage_skills", () => createManageSkillsTool(this.plugin.skillsService, this.plugin.app, agentCfg.id)],
["ask_question", () => createAskQuestionTool(agentCfg.id)],
];

for (const [toolId, factory] of builtInTools) {
Expand Down
85 changes: 85 additions & 0 deletions src/agent/tools/askQuestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { tool } from "@langchain/core/tools";
import type { RunnableConfig } from "@langchain/core/runnables";
import { z } from "zod";
import { DEFAULT_TOOLS_CONFIG } from "./builtInToolDefaults";
import { resolveToolAgent } from "./toolAgentContext";
import { getPendingQuestionStore, type QuestionItem } from "../../stores/pendingQuestionStore.svelte";
import { genUUIDv7 } from "../../utils/uuid7Validator";
import { Logger } from "../../utils/logging";

const questionItemSchema = z.object({
id: z
.string()
.optional()
.describe("Optional unique identifier for this question (e.g. 'choice_format' or 'framework')"),
Comment on lines +10 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Duplicate question IDs break answers

The schema allows repeated question IDs, including an explicit ID that can collide with a generated q_<index> fallback. The card then uses each ID as both a Svelte list key and the key for its answer state. A call with duplicate IDs can fail to render or make separate questions share selections, preventing the user from answering them independently. Please enforce uniqueness after fallback IDs are assigned.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent/tools/askQuestion.ts
Line: 10-14

Comment:
**Duplicate question IDs break answers**

The schema allows repeated question IDs, including an explicit ID that can collide with a generated `q_<index>` fallback. The card then uses each ID as both a Svelte list key and the key for its answer state. A call with duplicate IDs can fail to render or make separate questions share selections, preventing the user from answering them independently. Please enforce uniqueness after fallback IDs are assigned.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

question: z.string().describe("The question text to present to the user"),
options: z.array(z.string()).min(2).describe("Selectable options for the user to choose from"),
is_multi_select: z
.boolean()
.optional()
.describe("Allow user to select multiple options. Default is false (single choice)."),
allow_custom: z.boolean().optional().describe("Allow user to type in a custom write-in response. Default is true."),
});

export const askQuestionSchema = z.object({
questions: z.array(questionItemSchema).min(1).describe("List of multiple-choice questions to ask the user"),
});

export type AskQuestionInput = z.infer<typeof askQuestionSchema>;

function getAskQuestionToolConfig(agentId: string) {
const defaultConfig = DEFAULT_TOOLS_CONFIG.ask_question;
if (!agentId) return defaultConfig;
const agent = resolveToolAgent(agentId);
return agent?.toolsConfig?.ask_question ?? defaultConfig;
}

function resolveThreadIdFromConfig(config: RunnableConfig | undefined): string {
const threadId = config?.configurable?.thread_id;
if (typeof threadId === "string" && threadId.length > 0) {
return threadId;
}
return "default_thread";
}

export function createAskQuestionTool(agentId = "") {
const toolConfig = getAskQuestionToolConfig(agentId);

return tool(
async ({ questions }: AskQuestionInput, config: RunnableConfig & { runId?: string }) => {
const threadId = resolveThreadIdFromConfig(config);
const toolCallId = (config as { toolCallId?: string })?.toolCallId ?? config?.runId ?? genUUIDv7();

Logger.log("[ask_question] Prompting user with questions:", {
threadId,
toolCallId,
count: questions.length,
});

const formattedQuestions: QuestionItem[] = questions.map((q, index) => ({
id: q.id?.trim() || `q_${index + 1}`,
question: q.question,
options: q.options,
isMultiSelect: q.is_multi_select ?? false,
allowCustom: q.allow_custom ?? true,
}));

const store = getPendingQuestionStore();
const answers = await store.ask(threadId, toolCallId, formattedQuestions, config?.signal);

return JSON.stringify(
{
status: "answered",
answers,
},
null,
2,
);
},
{
name: toolConfig?.name ?? DEFAULT_TOOLS_CONFIG.ask_question.name,
description: toolConfig?.description ?? DEFAULT_TOOLS_CONFIG.ask_question.description,
schema: askQuestionSchema,
},
);
}
11 changes: 11 additions & 0 deletions src/agent/tools/builtInToolDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,17 @@ export const BUILT_IN_TOOL_DEFAULTS: Record<BuiltInToolId, BuiltInToolDefault> =
"Create new skills, revise your own attached skills, or delete skills you created. Changes apply immediately. A skill's name and plugin link are locked once created; only the body and description can change.",
},
},
ask_question: {
displayName: "Ask Question",
summary:
"Ask the user one or more multiple-choice questions to clarify requirements, solicit preferences, or choose between options. Execution pauses until the user responds in chat.",
config: {
enabled: true,
name: "ask_question",
description:
"Ask the user one or more multiple-choice questions to clarify requirements, solicit preferences, or choose between options. Execution pauses until the user responds in chat.",
},
},
};

/**
Expand Down
Loading
Loading