Unverified 提交 d6ab8290 authored 作者: wwwillchen-bot's avatar wwwillchen-bot 提交者: GitHub

feat: add max tool call steps setting (#2900)

## Summary - Add a new setting to configure the maximum number of tool call steps for local agent interactions - Users can choose from Low (25), Default (50), High (100), Very High (250) options - The setting is integrated into the settings page with search index support ## Test plan - [ ] Verify the setting appears in Settings > Agent Settings - [ ] Change the value and confirm it persists after app restart - [ ] Verify the agent respects the configured limit during tool calls - [ ] Run the new e2e tests to confirm UI interactions work correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2900" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> Co-authored-by: 's avatarWill Chen <willchen90@gmail.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 1c3dffaa
import { testSkipIfWindows } from "./helpers/test_helper";
/**
* Test for configuring max tool call steps setting
*/
testSkipIfWindows("max tool call steps setting", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
// Go to settings and change the max tool call steps
await po.navigation.goToSettingsTab();
const beforeSettings1 = po.settings.recordSettings();
// Change to Low (25)
await po.page
.getByRole("combobox", { name: "Max Tool Calls (Agent)" })
.click();
await po.page.getByRole("option", { name: "Low (25)" }).click();
po.settings.snapshotSettingsDelta(beforeSettings1);
// Verify the setting persists
await po.page.getByText("Go Back").click();
await po.navigation.goToSettingsTab();
const beforeSettings2 = po.settings.recordSettings();
// Change to High (100)
await po.page
.getByRole("combobox", { name: "Max Tool Calls (Agent)" })
.click();
await po.page.getByRole("option", { name: "High (100)" }).click();
po.settings.snapshotSettingsDelta(beforeSettings2);
// Change back to Default
await po.page.getByText("Go Back").click();
await po.navigation.goToSettingsTab();
const beforeSettings3 = po.settings.recordSettings();
await po.page
.getByRole("combobox", { name: "Max Tool Calls (Agent)" })
.click();
await po.page.getByRole("option", { name: "Default (50)" }).click();
po.settings.snapshotSettingsDelta(beforeSettings3);
});
- "maxToolCallSteps": 25
+ "maxToolCallSteps": 100
\ No newline at end of file
import React from "react";
import { useSettings } from "@/hooks/useSettings";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DEFAULT_MAX_TOOL_CALL_STEPS } from "@/constants/settings_constants";
import { useTranslation } from "react-i18next";
interface OptionInfo {
value: string;
label: string;
description: string;
}
const defaultValue = "default";
const options: OptionInfo[] = [
{
value: "25",
label: "Low (25)",
description:
"Limits tool calls to 25. Good for simple tasks that don't need many steps.",
},
{
value: defaultValue,
label: `Default (${DEFAULT_MAX_TOOL_CALL_STEPS})`,
description: "Balanced limit for most tasks.",
},
{
value: "100",
label: "High (100)",
description: "Extended limit for complex multi-step tasks.",
},
{
value: "200",
label: "Very High (200)",
description:
"Maximum tool calls for very complex tasks (may increase cost and time).",
},
];
export const MaxToolCallStepsSelector: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
const handleValueChange = (value: string) => {
if (value === "default") {
updateSettings({ maxToolCallSteps: undefined });
} else {
const numValue = parseInt(value, 10);
updateSettings({ maxToolCallSteps: numValue });
}
};
// Determine the current value
const currentValue = settings?.maxToolCallSteps?.toString() || defaultValue;
// Find the current option to display its description
const currentOption =
options.find((opt) => opt.value === currentValue) || options[1];
return (
<div className="space-y-1">
<div className="flex items-center gap-4">
<label
htmlFor="max-tool-call-steps"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{t("ai.maxToolCallSteps")}
</label>
<Select
value={currentValue}
onValueChange={(v) => v && handleValueChange(v)}
>
<SelectTrigger className="w-[180px]" id="max-tool-call-steps">
<SelectValue placeholder={t("ai.selectMaxToolCallSteps")} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{currentOption.description}
</div>
</div>
);
};
export const MAX_CHAT_TURNS_IN_CONTEXT = 3;
export const DEFAULT_MAX_TOOL_CALL_STEPS = 50;
......@@ -53,6 +53,9 @@
"maxChatTurns": "Max Chat Turns in Context",
"maxChatTurnsDescription": "Limits how many recent chat turns are included in context.",
"selectMaxChatTurns": "Select max turns",
"maxToolCallSteps": "Max Tool Calls (Agent)",
"maxToolCallStepsDescription": "Limits how many tool calls the agent can make before pausing.",
"selectMaxToolCallSteps": "Select max tool calls",
"unlimited": "Unlimited",
"turnCount_one": "{{count}} turn",
"turnCount_other": "{{count}} turns",
......
......@@ -313,6 +313,7 @@ const BaseUserSettingsFields = {
experiments: ExperimentsSchema.optional(),
lastShownReleaseNotesVersion: z.string().optional(),
maxChatTurnsInContext: z.number().optional(),
maxToolCallSteps: z.number().optional(),
thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
enableProLazyEditsMode: z.boolean().optional(),
proLazyEditsMode: z.enum(["off", "v1", "v2"]).optional(),
......
......@@ -25,6 +25,7 @@ export const SETTING_IDS = {
chatCompletionNotification: "setting-chat-completion-notification",
thinkingBudget: "setting-thinking-budget",
maxChatTurns: "setting-max-chat-turns",
maxToolCallSteps: "setting-max-tool-call-steps",
contextCompaction: "setting-context-compaction",
telemetry: "setting-telemetry",
github: "setting-github",
......@@ -157,6 +158,23 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [
sectionId: SECTION_IDS.ai,
sectionLabel: "AI",
},
{
id: SETTING_IDS.maxToolCallSteps,
label: "Max Tool Calls (Agent)",
description: "Set the maximum number of tool calls for local agent mode",
keywords: [
"tool",
"calls",
"max",
"limit",
"agent",
"steps",
"local",
"loop",
],
sectionId: SECTION_IDS.ai,
sectionLabel: "AI",
},
{
id: SETTING_IDS.contextCompaction,
label: "Context Compaction",
......
......@@ -7,6 +7,7 @@ import { showSuccess, showError } from "@/lib/toast";
import { AutoApproveSwitch } from "@/components/AutoApproveSwitch";
import { TelemetrySwitch } from "@/components/TelemetrySwitch";
import { MaxChatTurnsSelector } from "@/components/MaxChatTurnsSelector";
import { MaxToolCallStepsSelector } from "@/components/MaxToolCallStepsSelector";
import { ThinkingBudgetSelector } from "@/components/ThinkingBudgetSelector";
import { useSettings } from "@/hooks/useSettings";
import { useAppVersion } from "@/hooks/useAppVersion";
......@@ -414,6 +415,10 @@ export function AISettings() {
<MaxChatTurnsSelector />
</div>
<div id={SETTING_IDS.maxToolCallSteps} className="mt-4">
<MaxToolCallStepsSelector />
</div>
<div id={SETTING_IDS.contextCompaction} className="space-y-1 mt-4">
<ContextCompactionSwitch />
<div className="text-sm text-gray-500 dark:text-gray-400">
......
......@@ -87,11 +87,7 @@ const MAX_TERMINATED_STREAM_RETRIES = 3;
const STREAM_RETRY_BASE_DELAY_MS = 400;
const STREAM_CONTINUE_MESSAGE =
"[System] Your previous response stream was interrupted by a transient network error. Continue from exactly where you left off and do not repeat text that has already been sent.";
/**
* Maximum number of tool call steps before pausing.
* This prevents runaway loops while allowing complex multi-step tasks.
*/
const MAX_TOOL_CALL_STEPS = 50;
import { DEFAULT_MAX_TOOL_CALL_STEPS } from "@/constants/settings_constants";
// ============================================================================
// Tool Streaming State Management
......@@ -272,6 +268,8 @@ export async function handleLocalAgentStream(
},
): Promise<boolean> {
const settings = readSettings();
const maxToolCallSteps =
settings.maxToolCallSteps ?? DEFAULT_MAX_TOOL_CALL_STEPS;
let fullResponse = "";
let streamingPreview = ""; // Temporary preview for current tool, not persisted
let activeRetryReplayEvents: RetryReplayEvent[] | null = null;
......@@ -675,7 +673,7 @@ export async function handleLocalAgentStream(
messages: attemptMessages,
tools: allTools,
stopWhen: [
stepCountIs(MAX_TOOL_CALL_STEPS),
stepCountIs(maxToolCallSteps),
hasToolCall(addIntegrationTool.name),
// In plan mode, also stop after writing a plan or exiting plan mode.
...(planModeOnly
......@@ -1175,11 +1173,11 @@ export async function handleLocalAgentStream(
}
// Check if we hit the step limit and append a notice to the response
if (totalStepsExecuted >= MAX_TOOL_CALL_STEPS) {
if (totalStepsExecuted >= maxToolCallSteps) {
logger.info(
`Chat ${req.chatId} hit step limit of ${MAX_TOOL_CALL_STEPS} steps`,
`Chat ${req.chatId} hit step limit of ${maxToolCallSteps} steps`,
);
const stepLimitMessage = `\n\n<dyad-step-limit steps="${totalStepsExecuted}" limit="${MAX_TOOL_CALL_STEPS}">Automatically paused after ${totalStepsExecuted} tool calls.</dyad-step-limit>`;
const stepLimitMessage = `\n\n<dyad-step-limit steps="${totalStepsExecuted}" limit="${maxToolCallSteps}">Automatically paused after ${totalStepsExecuted} tool calls.</dyad-step-limit>`;
fullResponse += stepLimitMessage;
await updateResponseInDb(placeholderMessageId, fullResponse);
sendResponseChunk(
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论