diff --git a/README.md b/README.md index e818e95518b..77795f3b813 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ ## 🎉 News +- **2026-06-17**: Fixed React error #185 — state update on unmounted component in the CLI UI layer. Added `let cancelled` guards to 5 async hooks (`useShellHistory`, `useLogger`, `useCommandMigration`, `useGitBranchName`, `useWorktreeSession`) and removed unmount-time `setState` from `AgentChatContent`. ([#5199](https://github.com/QwenLM/qwen-code/issues/5199)) + - **2026-04-15**: Qwen OAuth free tier has been discontinued. To continue using Qwen Code, switch to [Alibaba Cloud Coding Plan](https://modelstudio.console.alibabacloud.com/?tab=coding-plan#/efm/coding-plan-index), [OpenRouter](https://openrouter.ai), [Fireworks AI](https://app.fireworks.ai), or bring your own API key. Run `qwen auth` to configure. - **2026-04-13**: Qwen OAuth free tier policy update: daily quota adjusted to 100 requests/day (from 1,000). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000000..8ffa00c48e7 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,564 @@ +
+ +[![npm version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code) +[![License](https://img.shields.io/github/license/QwenLM/qwen-code.svg)](./LICENSE) +[![Node.js Version](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen.svg)](https://nodejs.org/) +[![Downloads](https://img.shields.io/npm/dm/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code) + +QwenLM%2Fqwen-code | Trendshift + +**一个开源的 AI 代理,运行在你的终端中。** + +English | +Deutsch | +français | +日本語 | +Русский | +Português (Brasil) + +
+ +> 📖 此中文版本由社区维护。英文版 [README.md](./README.md) 为官方权威版本。 + +## 🎉 新闻 + +- **2026-06-17**: 修复 React error #185 — 组件卸载后调用 setState 导致的 CLI UI 层崩溃。为 5 个异步 hook(`useShellHistory`、`useLogger`、`useCommandMigration`、`useGitBranchName`、`useWorktreeSession`)添加了 `let cancelled` 防护模式,并从 `AgentChatContent` 移除了卸载时的 `setState` 调用。([#5199](https://github.com/QwenLM/qwen-code/issues/5199)) + +- **2026-04-15**: Qwen OAuth 免费层已停止服务。如需继续使用 Qwen Code,请切换到[阿里云百炼 Coding Plan](https://modelstudio.console.alibabacloud.com/?tab=coding-plan#/efm/coding-plan-index)、[OpenRouter](https://openrouter.ai)、[Fireworks AI](https://app.fireworks.ai),或使用自己的 API Key。运行 `qwen auth` 重新配置。 + +- **2026-04-13**: Qwen OAuth 免费层策略更新:每日配额调整为 100 次请求/天(之前为 1,000 次)。 + +- **2026-04-02**: Qwen3.6-Plus 现已上线!从[阿里云百炼 ModelStudio](https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=doc#/doc/?type=model&url=2840914_2&modelId=qwen3.6-plus) 获取 API Key,通过 OpenAI 兼容接口使用。 + +- **2026-02-16**: Qwen3.5-Plus 现已上线! + +## 为什么选择 Qwen Code? + +Qwen Code 是一个开源的终端 AI 代理,专为 Qwen 系列模型优化。它帮助你理解大型代码库、自动化繁琐工作并加速交付。 + +- **多协议、灵活的提供商**:支持 OpenAI / Anthropic / Gemini 兼容 API、[阿里云百炼 Coding Plan](https://modelstudio.console.alibabacloud.com/?tab=coding-plan#/efm/coding-plan-index)、[OpenRouter](https://openrouter.ai)、[Fireworks AI](https://app.fireworks.ai),或使用你自己的 API Key。 +- **开源、共同进化**:框架和 Qwen3-Coder 模型都是开源的——它们一起发布和进化。 +- **代理工作流、功能丰富**:丰富的内置工具(Skills、SubAgents)提供完整的代理工作流体验,类似 Claude Code。 +- **终端优先、IDE 友好**:为在命令行中工作的开发者设计,同时可选集成 VS Code、Zed 和 JetBrains IDE。 + +![](https://gw.alicdn.com/imgextra/i1/O1CN01D2DviS1wwtEtMwIzJ_!!6000000006373-2-tps-1600-900.png) + +## 安装 + +### 快速安装(推荐) + +#### Linux / macOS + +```bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash +``` + +#### Windows + +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex +``` + +> **注意**:建议安装后重启终端以确保环境变量生效。 + +### 手动安装 + +#### 系统要求 + +确保你已安装 Node.js 22 或更高版本。从 [nodejs.org](https://nodejs.org/en/download) 下载。 + +#### NPM + +```bash +npm install -g @qwen-code/qwen-code@latest +``` + +#### Homebrew(macOS、Linux) + +```bash +brew install qwen-code +``` + +## 快速开始 + +```bash +# 启动 Qwen Code(交互模式) +qwen + +# 然后在会话中: +/help +/auth +``` + +首次使用时会提示登录。你可以随时运行 `/auth` 切换认证方式。 + +示例提示词: + +```text +这个项目是做什么的? +解释代码库结构。 +帮我重构这个函数。 +为此模块生成单元测试。 +``` + +
+点击观看演示视频 + + + +
+ +## 🦞 在 Claw 中使用 Qwen Code 进行编码任务 + +将以下提示词复制并粘贴到你的代理中: + +``` +Use Qwen Code as a coding agent. First, fetch and read this skill reference: +https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/.qwen/skills/qwen-code-claw/SKILL.md +Then follow the instructions inside to install, authenticate, and use Qwen Code via acpx for all coding tasks. +``` + +## 认证方式 + +Qwen Code 支持以下认证方式: + +- **API Key(推荐)**:使用来自阿里云百炼 ModelStudio([国内站](https://bailian.console.aliyun.com/) / [国际站](https://modelstudio.console.alibabacloud.com/))或任何支持的提供商(OpenAI、Anthropic、Google GenAI 及其他兼容端点)的 API Key。 +- **Coding Plan**:订阅阿里云百炼 Coding Plan([国内站](https://bailian.console.aliyun.com/cn-beijing?tab=coding-plan#/efm/coding-plan-index) / [国际站](https://modelstudio.console.alibabacloud.com/?tab=coding-plan#/efm/coding-plan-index)),按月付费,配额更高。 + +> ⚠️ **Qwen OAuth 已于 2026 年 4 月 15 日停止服务。** 如果你之前使用 Qwen OAuth,请切换到上述方式之一。运行 `qwen`,然后运行 `/auth` 重新配置。 + +#### API Key(推荐) + +使用 API Key 连接到阿里云百炼 ModelStudio 或任何支持的提供商。支持多种协议: + +- **OpenAI 兼容**:阿里云百炼 ModelStudio、ModelScope、OpenAI、OpenRouter 及其他 OpenAI 兼容提供商 +- **Anthropic**:Claude 模型 +- **Google GenAI**:Gemini 模型 + +**推荐**的配置方式是在 `~/.qwen/settings.json` 中编辑(如果不存在则创建)。此文件允许你在一个地方定义所有可用的模型、API Key 和默认设置。 + +##### 三步快速配置 + +**第一步:** 创建或编辑 `~/.qwen/settings.json` + +以下是一个完整示例: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3.6-plus", + "name": "qwen3.6-plus", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "description": "通过 Dashscope 使用 Qwen3-Coder", + "envKey": "DASHSCOPE_API_KEY" + } + ] + }, + "env": { + "DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3.6-plus" + } +} +``` + +**第二步:** 理解每个字段 + +| 字段 | 作用 | +| --- | --- | +| `modelProviders` | 声明哪些模型可用以及如何连接。如 `openai`、`anthropic`、`gemini` 代表 API 协议。 | +| `modelProviders[].id` | 发送给 API 的模型 ID(如 `qwen3.6-plus`、`gpt-4o`)。 | +| `modelProviders[].envKey` | 保存 API Key 的环境变量名称。 | +| `modelProviders[].baseUrl` | API 端点 URL(非默认端点时需要)。 | +| `env` | 备用存储 API Key 的地方(优先级最低;敏感 Key 优先使用 `.env` 文件或 `export`)。 | +| `security.auth.selectedType` | 启动时使用的协议(`openai`、`anthropic`、`gemini`、`vertex-ai`)。 | +| `model.name` | Qwen Code 启动时使用的默认模型。 | + +**第三步:** 启动 Qwen Code——配置自动生效: + +```bash +qwen +``` + +随时使用 `/model` 命令切换所有已配置的模型。 + +##### 更多示例 + +
+Coding Plan(阿里云百炼 ModelStudio)— 按月付费,配额更高 + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3.6-plus", + "name": "qwen3.6-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "来自 ModelStudio Coding Plan 的 qwen3.6-plus", + "envKey": "BAILIAN_CODING_PLAN_API_KEY" + }, + { + "id": "qwen3.5-plus", + "name": "qwen3.5-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "来自 ModelStudio Coding Plan 的 qwen3.5-plus(开启思考)", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + } + ] + }, + "env": { + "BAILIAN_CODING_PLAN_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3.6-plus" + } +} +``` + +> 前往[阿里云百炼 ModelStudio(国内站)](https://bailian.console.aliyun.com/cn-beijing?tab=coding-plan#/efm/coding-plan-index)或[阿里云百炼 ModelStudio(国际站)](https://modelstudio.console.alibabacloud.com/?tab=coding-plan#/efm/coding-plan-index)订阅 Coding Plan 并获取 API Key。 + +
+ +
+多提供商(OpenAI + Anthropic + Gemini) + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1" + } + ], + "anthropic": [ + { + "id": "claude-sonnet-4-20250514", + "name": "Claude Sonnet 4", + "envKey": "ANTHROPIC_API_KEY" + } + ], + "gemini": [ + { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "envKey": "GEMINI_API_KEY" + } + ] + }, + "env": { + "OPENAI_API_KEY": "sk-xxxxxxxxxxxxx", + "ANTHROPIC_API_KEY": "sk-ant-xxxxxxxxxxxxx", + "GEMINI_API_KEY": "AIzaxxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "gpt-4o" + } +} +``` + +
+ +
+启用思考模式(适用于 qwen3.5-plus 等支持思考的模型) + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3.5-plus", + "name": "qwen3.5-plus (thinking)", + "envKey": "DASHSCOPE_API_KEY", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + } + ] + }, + "env": { + "DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3.5-plus" + } +} +``` + +
+ +> **提示:** 你也可以通过 shell 中的 `export` 或 `.env` 文件设置 API Key,它们的优先级高于 `settings.json` → `env`。详见[认证指南](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/)。 + +> **安全提示:** 切勿将 API Key 提交到版本控制。`~/.qwen/settings.json` 位于你的主目录,应保持私有。 + +#### 本地模型配置(Ollama / vLLM) + +你也可以在本地运行模型——无需 API Key 或云账号。这不是认证方式,而是通过 `~/.qwen/settings.json` 中的 `modelProviders` 字段配置本地模型端点。 + +在匹配的 provider 条目内设置 `generationConfig.contextWindowSize`,并将其调整为本地服务器上配置的上下文长度。 + +
+Ollama 配置 + +1. 从 [ollama.com](https://ollama.com/) 安装 Ollama +2. 拉取模型:`ollama pull qwen3:32b` +3. 配置 `~/.qwen/settings.json`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3:32b", + "name": "Qwen3 32B (Ollama)", + "baseUrl": "http://localhost:11434/v1", + "description": "通过 Ollama 在本地运行 Qwen3 32B", + "generationConfig": { + "contextWindowSize": 131072 + } + } + ] + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3:32b" + } +} +``` + +
+ +
+vLLM 配置 + +1. 安装 vLLM:`pip install vllm` +2. 启动服务器:`vllm serve Qwen/Qwen3-32B` +3. 配置 `~/.qwen/settings.json`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "Qwen/Qwen3-32B", + "name": "Qwen3 32B (vLLM)", + "baseUrl": "http://localhost:8000/v1", + "description": "通过 vLLM 在本地运行 Qwen3 32B", + "generationConfig": { + "contextWindowSize": 131072 + } + } + ] + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "Qwen/Qwen3-32B" + } +} +``` + +
+ +## 使用方式 + +作为一个开源的终端 AI 代理,你可以通过五种主要方式使用 Qwen Code: + +1. 交互模式(终端 UI) +2. 无头模式(脚本、CI) +3. IDE 集成(VS Code、Zed) +4. SDK(TypeScript、Python、Java) +5. 守护进程模式 — `qwen serve` 通过 HTTP+SSE 暴露 ACP,让多个客户端共享一个代理(实验性) + +#### 交互模式 + +```bash +cd your-project/ +qwen +``` + +在项目文件夹中运行 `qwen` 启动交互式终端 UI。使用 `@` 引用本地文件(例如 `@src/main.ts`)。 + +#### 无头模式 + +```bash +cd your-project/ +qwen -p "你的问题" +``` + +使用 `-p` 参数运行 Qwen Code 而无需交互 UI —— 适用于脚本、自动化和 CI/CD。了解更多:[无头模式](https://qwenlm.github.io/qwen-code-docs/en/users/features/headless)。 + +#### IDE 集成 + +在编辑器中使用 Qwen Code(VS Code、Zed 和 JetBrains IDE): + +- [在 VS Code 中使用](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/) +- [在 Zed 中使用](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/) +- [在 JetBrains IDE 中使用](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/) + +#### 守护进程模式(`qwen serve`,实验性) + +```bash +cd your-project/ +qwen serve +# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge) +``` + +将 Qwen Code 作为本地 HTTP 守护进程运行,以便 IDE 插件、Web UI、CI 脚本和自定义 CLI 都能**共享**一个代理会话——而不是各自启动子进程。回环绑定默认无认证(设置 `QWEN_SERVER_TOKEN` 可在回环上也启用 Bearer 认证);远程绑定(`--hostname 0.0.0.0`)**必需** token——缺少 token 时启动被拒绝。参见: + +- [守护进程模式用户指南](https://qwenlm.github.io/qwen-code-docs/en/users/qwen-serve) +- [HTTP 协议参考](https://qwenlm.github.io/qwen-code-docs/en/developers/qwen-serve-protocol) +- [DaemonClient TypeScript 快速入门](https://qwenlm.github.io/qwen-code-docs/en/developers/examples/daemon-client-quickstart) + +#### SDK + +基于 Qwen Code 构建: + +- TypeScript:[使用 Qwen Code SDK](./packages/sdk-typescript/README.md) +- Python:[使用 Python SDK](./packages/sdk-python/README.md) +- Java:[使用 Java SDK](./packages/sdk-java/qwencode/README.md) + +Python SDK 示例: + +```python +import asyncio + +from qwen_code_sdk import is_sdk_result_message, query + + +async def main() -> None: + result = query( + "总结仓库布局。", + { + "cwd": "/path/to/project", + "path_to_qwen_executable": "qwen", + }, + ) + + async for message in result: + if is_sdk_result_message(message): + print(message["result"]) + + +asyncio.run(main()) +``` + +## 命令与快捷键 + +### 会话命令 + +- `/help` — 显示可用命令 +- `/clear` — 清除对话历史 +- `/compress` — 压缩历史以节省 token +- `/stats` — 显示当前会话信息 +- `/bug` — 提交 Bug 报告 +- `/exit` 或 `/quit` — 退出 Qwen Code + +### 键盘快捷键 + +- `Ctrl+C` — 取消当前操作 +- `Ctrl+D` — 退出(空行时) +- `上/下` — 浏览命令历史 + +> 了解更多关于[命令](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/)的信息 +> +> **提示**:在 YOLO 模式(`--yolo`)下,检测到图片时自动切换视觉模式而无需提示。了解更多关于[审批模式](https://qwenlm.github.io/qwen-code-docs/en/users/features/approval-mode/)。 + +## 配置 + +Qwen Code 可通过 `settings.json`、环境变量和 CLI 参数进行配置。 + +| 文件 | 范围 | 描述 | +| --- | --- | --- | +| `~/.qwen/settings.json` | 用户(全局) | 适用于所有 Qwen Code 会话。**推荐用于 `modelProviders` 和 `env`。** | +| `.qwen/settings.json` | 项目 | 仅在该项目中运行 Qwen Code 时生效。覆盖用户设置。 | + +`settings.json` 中最常用的顶级字段: + +| 字段 | 描述 | +| --- | --- | +| `modelProviders` | 按协议定义可用模型(`openai`、`anthropic`、`gemini`、`vertex-ai`)。 | +| `env` | 备用环境变量(如 API Key)。优先级低于 shell `export` 和 `.env` 文件。 | +| `security.auth.selectedType` | 启动时使用的协议(如 `openai`)。 | +| `model.name` | Qwen Code 启动时使用的默认模型。 | + +> 参见上方[认证](#api-key推荐)部分获取完整的 `settings.json` 示例,以及[设置参考](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/)获取所有可用选项。 + +## 基准测试结果 + +### Terminal-Bench 性能 + +| 代理 | 模型 | 准确率 | +| --- | --- | --- | +| Qwen Code | Qwen3-Coder-480A35 | 37.5% | +| Qwen Code | Qwen3-Coder-30BA3B | 31.3% | + +## 生态系统 + +寻找图形界面? + +- [**Qwen Code Desktop**](https://github.com/QwenLM/qwen-code/releases/tag/desktop-latest) 官方桌面应用(macOS、Windows、Linux) +- [**AionUi**](https://github.com/iOfficeAI/AionUi) 为包括 Qwen Code 在内的命令行 AI 工具提供的现代 GUI +- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) 面向 Qwen Code 的跨平台桌面/Web/移动端 UI + +## 故障排除 + +如果遇到问题,请查看[故障排除指南](https://qwenlm.github.io/qwen-code-docs/en/users/support/troubleshooting/)。 + +**常见问题:** + +- **`Qwen OAuth 免费层已于 2026-04-15 停止服务`**:Qwen OAuth 已不再可用。运行 `qwen` → `/auth` 切换到 API Key 或 Coding Plan。参见上方[认证](#api-key推荐)部分的配置说明。 + +要报告 Bug,请在 CLI 中运行 `/bug` 并附带简短的标题和复现步骤。 + +## 联系我们 + +- Discord: https://discord.gg/RN7tqZCeDK +- 钉钉: https://qr.dingtalk.com/action/joingroup?code=v1,k1,+FX6Gf/ZDlTahTIRi8AEQhIaBlqykA0j+eBKKdhLeAE=&_dt_no_comment=1&origin=1 + +## 致谢 + +本项目基于 [Google Gemini CLI](https://github.com/google-gemini/gemini-cli)。我们感谢并认可 Gemini CLI 团队的出色工作。我们的主要贡献侧重于解析器级别的适配,以更好地支持 Qwen-Coder 模型。 diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx index 9f086df4f96..5dfe520e871 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx @@ -129,7 +129,10 @@ export const AgentChatContent = ({ useEffect(() => { if (readonly) return; setAgentShellFocused(embeddedShellFocused); - return () => setAgentShellFocused(false); + // Intentionally not resetting on unmount: calling setState on a parent + // context provider during effect cleanup triggers React error #185 + // ("Cannot update a component while rendering a different component") + // when both child and provider unmount in the same commit phase. }, [embeddedShellFocused, readonly, setAgentShellFocused]); useEffect(() => { diff --git a/packages/cli/src/ui/hooks/useCommandMigration.ts b/packages/cli/src/ui/hooks/useCommandMigration.ts index 191694089ba..9de6894ab6d 100644 --- a/packages/cli/src/ui/hooks/useCommandMigration.ts +++ b/packages/cli/src/ui/hooks/useCommandMigration.ts @@ -21,26 +21,34 @@ export function useCommandMigration( const [tomlFiles, setTomlFiles] = useState([]); useEffect(() => { + let cancelled = false; + const checkTomlCommands = async () => { const allFiles: string[] = []; // Check workspace commands directory (.qwen/commands) const workspaceCommandsDir = storage.getProjectCommandsDir(); const workspaceFiles = await detectTomlCommands(workspaceCommandsDir); + if (cancelled) return; allFiles.push(...workspaceFiles.map((f) => `workspace: ${f}`)); // Check user commands directory (~/.qwen/commands) const userCommandsDir = Storage.getUserCommandsDir(); const userFiles = await detectTomlCommands(userCommandsDir); + if (cancelled) return; allFiles.push(...userFiles.map((f) => `user: ${f}`)); - if (allFiles.length > 0) { + if (!cancelled && allFiles.length > 0) { setTomlFiles(allFiles); setShowMigrationNudge(true); } }; checkTomlCommands(); + + return () => { + cancelled = true; + }; }, [storage]); return { diff --git a/packages/cli/src/ui/hooks/useGitBranchName.ts b/packages/cli/src/ui/hooks/useGitBranchName.ts index 326051a02e9..4583f611d24 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; @@ -13,63 +13,65 @@ import path from 'node:path'; export function useGitBranchName(cwd: string): string | undefined { const [branchName, setBranchName] = useState(undefined); - const fetchBranchName = useCallback(async () => { - try { - if (!isCommandAvailable('git').available) { - return; - } + useEffect(() => { + let cancelled = false; + + const fetchWithGuard = async () => { + try { + if (!isCommandAvailable('git').available) { + return; + } - const { stdout } = await execCommand( - 'git', - ['rev-parse', '--abbrev-ref', 'HEAD'], - { cwd }, - ); - const branch = stdout.toString().trim(); - if (branch && branch !== 'HEAD') { - setBranchName(branch); - } else { - const { stdout: hashStdout } = await execCommand( + const { stdout } = await execCommand( 'git', - ['rev-parse', '--short', 'HEAD'], + ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }, ); - setBranchName(hashStdout.toString().trim()); + if (cancelled) return; + const branch = stdout.toString().trim(); + if (branch && branch !== 'HEAD') { + setBranchName(branch); + } else { + const { stdout: hashStdout } = await execCommand( + 'git', + ['rev-parse', '--short', 'HEAD'], + { cwd }, + ); + if (!cancelled) { + setBranchName(hashStdout.toString().trim()); + } + } + } catch { + if (!cancelled) setBranchName(undefined); } - } catch (_error) { - setBranchName(undefined); - } - }, [cwd, setBranchName]); + }; - useEffect(() => { - fetchBranchName(); // Initial fetch + fetchWithGuard(); const gitLogsHeadPath = path.join(cwd, '.git', 'logs', 'HEAD'); let watcher: fs.FSWatcher | undefined; const setupWatcher = async () => { try { - // Check if .git/logs/HEAD exists, as it might not in a new repo or orphaned head await fsPromises.access(gitLogsHeadPath, fs.constants.F_OK); + if (cancelled) return; watcher = fs.watch(gitLogsHeadPath, (eventType: string) => { - // Changes to .git/logs/HEAD (appends) indicate HEAD has likely changed if (eventType === 'change' || eventType === 'rename') { - // Handle rename just in case - fetchBranchName(); + if (!cancelled) fetchWithGuard(); } }); } catch (_watchError) { - // Silently ignore watcher errors (e.g. permissions or file not existing), - // similar to how exec errors are handled. - // The branch name will simply not update automatically. + // Silently ignore watcher errors } }; setupWatcher(); return () => { + cancelled = true; watcher?.close(); }; - }, [cwd, fetchBranchName]); + }, [cwd]); return branchName; } diff --git a/packages/cli/src/ui/hooks/useLogger.ts b/packages/cli/src/ui/hooks/useLogger.ts index bcd980ba94c..a388f836983 100644 --- a/packages/cli/src/ui/hooks/useLogger.ts +++ b/packages/cli/src/ui/hooks/useLogger.ts @@ -19,6 +19,8 @@ export const useLogger = (storage: Storage, sessionId: string) => { return; } + let cancelled = false; + const newLogger = new Logger(sessionId, storage); /** * Start async initialization, no need to await. Using await slows down the @@ -28,9 +30,15 @@ export const useLogger = (storage: Storage, sessionId: string) => { newLogger .initialize() .then(() => { - setLogger(newLogger); + if (!cancelled) { + setLogger(newLogger); + } }) .catch(() => {}); + + return () => { + cancelled = true; + }; }, [storage, sessionId]); return logger; diff --git a/packages/cli/src/ui/hooks/useShellHistory.ts b/packages/cli/src/ui/hooks/useShellHistory.ts index 69358d890b2..11b655df146 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.ts @@ -83,13 +83,22 @@ export function useShellHistory( const [historyFilePath, setHistoryFilePath] = useState(null); useEffect(() => { + let cancelled = false; + async function loadHistory() { const filePath = await getHistoryFilePath(projectRoot, storage); + if (cancelled) return; setHistoryFilePath(filePath); const loadedHistory = await readHistoryFile(filePath); - setHistory(loadedHistory.reverse()); // Newest first + if (!cancelled) { + setHistory(loadedHistory.reverse()); // Newest first + } } loadHistory(); + + return () => { + cancelled = true; + }; }, [projectRoot, storage]); const addCommandToHistory = useCallback( diff --git a/packages/cli/src/ui/hooks/useWorktreeSession.ts b/packages/cli/src/ui/hooks/useWorktreeSession.ts index 53e95203c14..88b919c690c 100644 --- a/packages/cli/src/ui/hooks/useWorktreeSession.ts +++ b/packages/cli/src/ui/hooks/useWorktreeSession.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; @@ -33,20 +33,22 @@ import { readWorktreeSession } from '@qwen-code/qwen-code-core'; export function useWorktreeSession(config: Config): WorktreeSession | null { const [session, setSession] = useState(null); - const load = useCallback(async () => { - try { - const filePath = config - .getSessionService() - .getWorktreeSessionPath(config.getSessionId()); - const ws = await readWorktreeSession(filePath); - setSession(ws); - } catch { - setSession(null); - } - }, [config]); - useEffect(() => { - void load(); + let cancelled = false; + + const safeLoad = async () => { + try { + const filePath = config + .getSessionService() + .getWorktreeSessionPath(config.getSessionId()); + const ws = await readWorktreeSession(filePath); + if (!cancelled) setSession(ws); + } catch { + if (!cancelled) setSession(null); + } + }; + + void safeLoad(); const filePath = config .getSessionService() @@ -55,35 +57,18 @@ export function useWorktreeSession(config: Config): WorktreeSession | null { const fileName = path.basename(filePath); let watcher: fs.FSWatcher | undefined; - let cancelled = false; const setupWatcher = async () => { try { - // Ensure the chats directory exists so fs.watch doesn't ENOENT - // when no session has ever been written for this project. The - // recursive mkdir is idempotent. await fsPromises.mkdir(dirPath, { recursive: true }); if (cancelled) return; - // Watch the parent dir so create/delete/rename events on the - // sidecar (which may not exist at mount time) are caught. - // - // `filename` may come back as a Buffer on Linux when no - // encoding is configured at the libuv layer, so the previous - // `filename === fileName` (string) comparison silently never - // matched and the watcher fired but never reloaded. Normalize - // via toString() to cover both shapes. `filename` is also - // nullable on some platforms (e.g. recursive watchers without - // event payloads) — treat null as "unknown file, reload to be - // safe" since the worktree state is small and the load is cheap. watcher = fs.watch(dirPath, (_eventType, filename) => { if (filename === null || filename.toString() === fileName) { - void load(); + void safeLoad(); } }); } catch { - // Watcher setup is best-effort: the hook still returns whatever - // load() resolved with on mount. Without a watcher, the UI just - // doesn't react to sidecar changes until the next re-mount. + // Watcher setup is best-effort } }; @@ -93,7 +78,7 @@ export function useWorktreeSession(config: Config): WorktreeSession | null { cancelled = true; watcher?.close(); }; - }, [config, load]); + }, [config]); return session; } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index f71066b2dc9..b226d4d2dda 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -6182,6 +6182,7 @@ Other open files: // Replace loop detector with spies const ldMock = { + checkAlwaysOnSafeties: vi.fn().mockReturnValue(false), addAndCheckDeterministicToolCallLoop: vi.fn().mockReturnValue(false), addAndCheckHeuristicLoops: vi.fn().mockReturnValue(false), reset: vi.fn(), @@ -6211,7 +6212,8 @@ Other open files: // consume stream } - // Assert - neither detector path runs when skipLoopDetection is true + // Assert - always-on safeties still run, but opt-in detectors don't + expect(ldMock.checkAlwaysOnSafeties).toHaveBeenCalled(); expect( ldMock.addAndCheckDeterministicToolCallLoop, ).not.toHaveBeenCalled(); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index c59b59eeef2..e78fed54f07 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -2123,6 +2123,26 @@ export class GeminiClient { didUpdateIdeContextState = true; } + // Always-on safety checks (turn tool-call cap). These fire before + // the skipLoopDetection gate so they cannot be bypassed by + // configuration. + const alwaysOnLoop = + this.loopDetector.checkAlwaysOnSafeties(event); + if (alwaysOnLoop) { + yield { + type: GeminiEventType.LoopDetected, + value: { loopType: this.loopDetector.getLastLoopType()! }, + }; + if (arenaAgentClient) { + await arenaAgentClient.reportError('Loop detected'); + } + this.lastApiCompletionTimestamp = Date.now(); + if (isTopLevelInteraction) + endInteractionSpan('error', { errorMessage: 'loop detected' }); + this.cancelPendingMemoryPrefetch(); + return turn; + } + // Loop detection is opt-in: `model.skipLoopDetection` defaults to true // (see settingsSchema) to avoid false-positive interruptions. Keep BOTH // the deterministic identical-tool-call check and the heuristic checks diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index d4ea53ee045..28825ad58ce 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -27,6 +27,9 @@ const CONTENT_CHUNK_SIZE = 50; // Mirrored from loopDetectionService.ts. Kept local so the test is // self-describing and failures point to the constant that changed. const FILE_READ_WINDOW = 15; +const GLOBAL_DUPLICATE_THRESHOLD = 6; +const ALTERNATING_PATTERN_CYCLES = 3; +const TURN_TOOL_CALL_CAP = 100; describe('LoopDetectionService', () => { let service: LoopDetectionService; @@ -1021,4 +1024,228 @@ describe('LoopDetectionService', () => { } }); }); + + describe('Turn Tool Call Cap (Always-On Circuit Breaker)', () => { + it('should not fire when total calls are below the cap', () => { + service.reset(''); + for (let i = 0; i < TURN_TOOL_CALL_CAP; i++) { + const isLoop = service.checkAlwaysOnSafeties( + createToolCallRequestEvent('any_tool', { i }), + ); + expect(isLoop).toBe(false); + } + }); + + it('should fire on the call that exceeds the cap', () => { + service.reset(''); + for (let i = 0; i < TURN_TOOL_CALL_CAP; i++) { + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('any_tool', { i }), + ); + } + const isLoop = service.checkAlwaysOnSafeties( + createToolCallRequestEvent('any_tool', { extra: true }), + ); + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); + }); + + it('should fire regardless of disabledForSession', () => { + service.reset(''); + service.disableForSession(); + // disableForSession prevents heuristic checks, but not the turn cap + for (let i = 0; i < TURN_TOOL_CALL_CAP; i++) { + service.checkAlwaysOnSafeties( + createToolCallRequestEvent('any_tool', { i }), + ); + } + const isLoop = service.checkAlwaysOnSafeties( + createToolCallRequestEvent('any_tool', { extra: true }), + ); + // disabledForSession blocks non-ToolCallRequest events in + // checkAlwaysOnSafeties, but this IS a ToolCallRequest so the cap + // still fires. + expect(isLoop).toBe(true); + }); + }); + + describe('Global Tool Call Duplicate Detection', () => { + it('should not fire when same call appears fewer than threshold times', () => { + service.reset(''); + const event = createToolCallRequestEvent('stuck_tool', { + param: 'same', + }); + for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD - 1; i++) { + const isLoop = service.addAndCheckHeuristicLoops(event); + expect(isLoop).toBe(false); + } + }); + + it('should fire when same (tool, args) appears threshold times non-consecutively', () => { + service.reset(''); + const stuckEvent = createToolCallRequestEvent('stuck_tool', { + param: 'same', + }); + const otherEvents = [ + createToolCallRequestEvent('other_a', { x: 1 }), + createToolCallRequestEvent('other_b', { y: 2 }), + createToolCallRequestEvent('other_c', { z: 3 }), + ]; + + // Interleave: stuck, other_a, stuck, other_b, stuck, other_c, ... + // GLOBAL_DUPLICATE_THRESHOLD total stuck calls with different calls between + let otherIdx = 0; + for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD - 1; i++) { + expect(service.addAndCheckHeuristicLoops(stuckEvent)).toBe(false); + expect( + service.addAndCheckHeuristicLoops( + otherEvents[otherIdx % otherEvents.length], + ), + ).toBe(false); + otherIdx++; + } + // The threshold-th stuck call should fire + const isLoop = service.addAndCheckHeuristicLoops(stuckEvent); + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'global_tool_call_duplicate', + }), + ); + }); + + it('should not fire for different (tool, args) pairs', () => { + service.reset(''); + for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD; i++) { + const isLoop = service.addAndCheckHeuristicLoops( + createToolCallRequestEvent('stuck_tool', { param: i }), + ); + expect(isLoop).toBe(false); + } + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('should fire for consecutive identical calls via both detectors', () => { + // The heuristic path also runs checkGlobalDuplicate on every + // ToolCallRequest, so a consecutive run of 5 identical calls trips + // the consecutive detector first (threshold 5 < global 6). This test + // verifies the global path would also fire if the consecutive + // detector were disabled. + service.reset(''); + const event = createToolCallRequestEvent('stuck_tool', { + param: 'same', + }); + for (let i = 0; i < GLOBAL_DUPLICATE_THRESHOLD - 1; i++) { + service.addAndCheckHeuristicLoops(event); + } + const isLoop = service.addAndCheckHeuristicLoops(event); + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'global_tool_call_duplicate', + }), + ); + }); + }); + + describe('Alternating Tool Call Pattern Detection', () => { + it('should fire for a clean ABABAB alternating pattern', () => { + service.reset(''); + const eventA = createToolCallRequestEvent('tool_a', { param: 'a' }); + const eventB = createToolCallRequestEvent('tool_b', { param: 'b' }); + + // ALTERNATING_PATTERN_CYCLES cycles = 2*CYCLES calls. Build up to + // one call short of the trigger. + const totalCycles = ALTERNATING_PATTERN_CYCLES; + for (let i = 0; i < totalCycles - 1; i++) { + expect(service.addAndCheckHeuristicLoops(eventA)).toBe(false); + expect(service.addAndCheckHeuristicLoops(eventB)).toBe(false); + } + // First call of the final cycle + expect(service.addAndCheckHeuristicLoops(eventA)).toBe(false); + // Second call of the final cycle completes the pattern + const isLoop = service.addAndCheckHeuristicLoops(eventB); + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'alternating_tool_call_pattern', + }), + ); + }); + + it('should not fire when calls alternate but with varying keys', () => { + service.reset(''); + // Alternating tool names but different args each time → different + // keys → no clean ABAB because the keys keep changing. + const totalCycles = ALTERNATING_PATTERN_CYCLES + 2; + for (let i = 0; i < totalCycles; i++) { + expect( + service.addAndCheckHeuristicLoops( + createToolCallRequestEvent('tool_a', { param: i }), + ), + ).toBe(false); + expect( + service.addAndCheckHeuristicLoops( + createToolCallRequestEvent('tool_b', { param: i }), + ), + ).toBe(false); + } + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('should not fire for a single tool repeated (consecutive, not alternating)', () => { + service.reset(''); + const event = createToolCallRequestEvent('tool_a', { param: 'a' }); + const totalCalls = 2 * ALTERNATING_PATTERN_CYCLES; + for (let i = 0; i < totalCalls; i++) { + // The consecutive identical detector would fire at threshold 5, + // but we only check the heuristic path here. At 6 calls the + // global duplicate detector fires. This test just confirms the + // alternating detector doesn't false-positive on a repeated key. + service.addAndCheckHeuristicLoops(event); + } + // Either global_duplicate or consecutive_identical fires — we just + // verify the alternating pattern detector didn't fire. + const logged = vi.mocked(loggers.logLoopDetected).mock.calls; + const alternatingFired = logged.some((call) => { + const event = call[1] as unknown as Record; + return 'loop_type' in event + ? event['loop_type'] === 'alternating_tool_call_pattern' + : false; + }); + expect(alternatingFired).toBe(false); + }); + + it('should reset alternating window after a different third pattern', () => { + service.reset(''); + const eventA = createToolCallRequestEvent('tool_a', { param: 'a' }); + const eventB = createToolCallRequestEvent('tool_b', { param: 'b' }); + const eventC = createToolCallRequestEvent('tool_c', { param: 'c' }); + + // Build up ABAB + service.addAndCheckHeuristicLoops(eventA); + service.addAndCheckHeuristicLoops(eventB); + service.addAndCheckHeuristicLoops(eventA); + service.addAndCheckHeuristicLoops(eventB); + // Insert C to break the pattern + service.addAndCheckHeuristicLoops(eventC); + // Restart ABAB from here — need 6 calls (3 cycles) after the break + service.addAndCheckHeuristicLoops(eventA); + service.addAndCheckHeuristicLoops(eventB); + service.addAndCheckHeuristicLoops(eventA); + service.addAndCheckHeuristicLoops(eventB); + expect(service.addAndCheckHeuristicLoops(eventA)).toBe(false); + const isLoop = service.addAndCheckHeuristicLoops(eventB); + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + loop_type: 'alternating_tool_call_pattern', + }), + ); + }); + }); }); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index f6e4d909332..b5cf1527a94 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -44,6 +44,20 @@ const FILE_READ_WINDOW = 15; // Action stagnation tracking const STAGNATION_THRESHOLD = 8; +// Global tool call duplicate tracking: how many times the same (tool, args) +// pair must appear across the entire turn (not necessarily consecutively) +// before it is treated as a loop. +const GLOBAL_DUPLICATE_THRESHOLD = 6; + +// Alternating pattern detection: number of complete AB cycles needed to +// trip the detector (3 cycles = 6 calls: A B A B A B). +const ALTERNATING_PATTERN_CYCLES = 3; + +// Hard per-turn tool call cap. Always-on circuit breaker — not gated by +// skipLoopDetection. If a single turn exceeds this many tool calls the +// turn is halted regardless of loop-detection configuration. +const TURN_TOOL_CALL_CAP = 100; + /** * Service for detecting and preventing infinite loops in AI responses. * Monitors tool call repetitions and content sentence repetitions. @@ -85,6 +99,19 @@ export class LoopDetectionService { // exploration rather than loop evidence. Resets per-prompt in reset(). private hasSeenNonReadTool = false; + // Non-consecutive global duplicate tracking: counts every (tool, args) + // pair seen across the entire turn. When any pair reaches + // GLOBAL_DUPLICATE_THRESHOLD, the turn is halted. + private globalToolCallCounts = new Map(); + + // Sliding window of recent tool-call keys for alternating-pattern + // detection (ABABAB…). Kept at 2 * ALTERNATING_PATTERN_CYCLES entries. + private recentToolCallKeys: string[] = []; + + // Total tool calls emitted in the current turn. Always-on circuit breaker; + // exceeds TURN_TOOL_CALL_CAP → hard-stop. + private turnToolCallTotal = 0; + // Loop type of the most recent firing. Bubbled up through the // LoopDetected event so callers (non-interactive CLI, telemetry) can tell // the user which detector actually fired. @@ -152,10 +179,17 @@ export class LoopDetectionService { this.thoughtHistory = []; this.trackToolCall(event.value); + const globalDup = this.checkGlobalDuplicate( + this.getToolCallKey(event.value), + ); + const alternating = this.checkAlternatingPattern( + this.getToolCallKey(event.value), + ); const readFileLoop = this.checkReadFileLoop(); const actionStagnation = this.checkActionStagnation(); - this.loopDetected = readFileLoop || actionStagnation; + this.loopDetected = + globalDup || alternating || readFileLoop || actionStagnation; break; } case GeminiEventType.Content: { @@ -198,6 +232,28 @@ export class LoopDetectionService { return this.loopDetected; } + /** + * Always-on safety checks that fire regardless of skipLoopDetection. + * Currently enforces the per-turn tool call cap. Call this before the + * gated checks so the hard cap cannot be bypassed by configuration. + */ + checkAlwaysOnSafeties(event: ServerGeminiStreamEvent): boolean { + if (this.loopDetected) { + return true; + } + + if (event.type !== GeminiEventType.ToolCallRequest) { + return false; + } + + const key = this.getToolCallKey(event.value); + if (this.checkTurnToolCallCap(key)) { + this.loopDetected = true; + return true; + } + return false; + } + private checkToolCallLoop(toolCall: { name: string; args: object }): boolean { const key = this.getToolCallKey(toolCall); if (this.lastToolCallKey === key) { @@ -550,6 +606,92 @@ export class LoopDetectionService { return false; } + /** + * Always-on hard cap: if the turn exceeds TURN_TOOL_CALL_CAP tool calls + * the turn is halted. This is a safety net independent of + * skipLoopDetection and fires on the very next tool call that pushes the + * total past the cap, not retroactively. + */ + private checkTurnToolCallCap(_toolCallKey: string): boolean { + this.turnToolCallTotal++; + if (this.turnToolCallTotal > TURN_TOOL_CALL_CAP) { + this.lastLoopType = LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS; + logLoopDetected( + this.config, + new LoopDetectedEvent( + LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, + this.promptId, + ), + ); + return true; + } + return false; + } + + /** + * Non-consecutive global duplicate detection: the SAME (tool, args) pair + * need not appear consecutively — if it appears GLOBAL_DUPLICATE_THRESHOLD + * times anywhere in the turn, it is treated as a loop. This catches models + * that intersperse the stuck call among other actions. + */ + private checkGlobalDuplicate(toolCallKey: string): boolean { + const count = (this.globalToolCallCounts.get(toolCallKey) ?? 0) + 1; + this.globalToolCallCounts.set(toolCallKey, count); + + if (count >= GLOBAL_DUPLICATE_THRESHOLD) { + this.lastLoopType = LoopType.GLOBAL_TOOL_CALL_DUPLICATE; + logLoopDetected( + this.config, + new LoopDetectedEvent( + LoopType.GLOBAL_TOOL_CALL_DUPLICATE, + this.promptId, + ), + ); + return true; + } + return false; + } + + /** + * Alternating-pattern detection: catches ABABAB… patterns where the model + * flips between two distinct tool calls. Tracked via a sliding window of + * tool-call keys; when the window fills with alternating A/B values the + * turn is halted. + */ + private checkAlternatingPattern(toolCallKey: string): boolean { + const maxLen = 2 * ALTERNATING_PATTERN_CYCLES; + this.recentToolCallKeys.push(toolCallKey); + if (this.recentToolCallKeys.length > maxLen) { + this.recentToolCallKeys.shift(); + } + + if (this.recentToolCallKeys.length < maxLen) { + return false; + } + + // Extract the two alternating keys. If there are more than two distinct + // keys in the window, there is no clean ABAB pattern. + const [a, b] = this.recentToolCallKeys; + if (a === b) return false; // not alternating, same tool + + for (let i = 0; i < maxLen; i++) { + const expected = i % 2 === 0 ? a : b; + if (this.recentToolCallKeys[i] !== expected) { + return false; + } + } + + this.lastLoopType = LoopType.ALTERNATING_TOOL_CALL_PATTERN; + logLoopDetected( + this.config, + new LoopDetectedEvent( + LoopType.ALTERNATING_TOOL_CALL_PATTERN, + this.promptId, + ), + ); + return true; + } + /** * Resets all loop detection state. */ @@ -566,6 +708,9 @@ export class LoopDetectionService { this.lastSeenToolName = null; this.hasSeenNonReadTool = false; this.lastLoopType = null; + this.globalToolCallCounts.clear(); + this.recentToolCallKeys = []; + this.turnToolCallTotal = 0; } private resetToolCallCount(): void { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index f33880c9815..bdd0d5f335a 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -424,6 +424,10 @@ export enum LoopType { REPETITIVE_THOUGHTS = 'repetitive_thoughts', READ_FILE_LOOP = 'read_file_loop', ACTION_STAGNATION = 'action_stagnation', + /** Same (tool, args) pair appears N times across the entire turn, not necessarily consecutively. */ + GLOBAL_TOOL_CALL_DUPLICATE = 'global_tool_call_duplicate', + /** Two tools alternating in a fixed pattern (A B A B A B ...). */ + ALTERNATING_TOOL_CALL_PATTERN = 'alternating_tool_call_pattern', } export class LoopDetectedEvent implements BaseTelemetryEvent { diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index f552c20391d..7324f515386 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -659,6 +659,7 @@ Usage notes: - If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls. - You can optionally set \`run_in_background: true\` to run the agent in the background. You will be notified when it completes. Use this when you have genuinely independent work to do in parallel and don't need the agent's results before you can proceed. - You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result so you can review or merge them. +- **Do not poll for subagent completion.** After dispatching background agents (\`run_in_background: true\`), do NOT repeatedly call tools like \`${ToolNames.READ_FILE}\`, \`${ToolNames.GLOB}\`, or \`${ToolNames.RUN_SHELL_COMMAND}\` to check whether they finished — this busy-wait pattern consumes tokens, bloats context, and can trigger the API's repetitive-call detection. Instead: wait for the \`\` XML block that arrives automatically when a background agent completes, or use \`${ToolNames.SEND_MESSAGE}\` to communicate with a running agent. If you need to wait for multiple agents before proceeding, state your intent clearly and the notification mechanism will deliver results as they arrive. ${ isForkSubagentEnabled(this.config) ? ` diff --git a/packages/core/src/tools/enterPlanMode.ts b/packages/core/src/tools/enterPlanMode.ts index 4585548b95f..7e9874e9e82 100644 --- a/packages/core/src/tools/enterPlanMode.ts +++ b/packages/core/src/tools/enterPlanMode.ts @@ -96,6 +96,38 @@ class EnterPlanModeToolInvocation extends BaseToolInvocation< }; } + // Reveal the exit_plan_mode deferred tool so the model can call it + // directly without needing to search for it first. This mirrors the + // pattern in ToolSearch's select: path (reveal + setTools sync). + try { + const registry = this.config.getToolRegistry(); + const exitPlanModeName = ToolNames.EXIT_PLAN_MODE; + const revealedBefore = registry.isDeferredToolRevealed(exitPlanModeName); + if (!revealedBefore) { + registry.revealDeferredTool(exitPlanModeName); + const geminiClient = this.config.getGeminiClient(); + if (geminiClient) { + try { + await geminiClient.setTools(); + } catch (setErr) { + // Rollback the reveal on setTools failure so the registry + // stays consistent with the chat's declaration list. + registry.unrevealDeferredTool(exitPlanModeName); + debugLogger.error( + `[EnterPlanModeTool] Failed to sync exit_plan_mode tool declaration: ${setErr instanceof Error ? setErr.message : String(setErr)}`, + ); + } + } + } + } catch (error) { + // Non-fatal: log the failure but still return success for + // entering plan mode. The model can use ToolSearch to find + // exit_plan_mode if the reveal failed. + debugLogger.warn( + `[EnterPlanModeTool] Failed to reveal exit_plan_mode: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return { llmContent: 'Plan mode is now active. Continue with read-only investigation, ask the user when needed, and use exit_plan_mode when the plan is ready.',