Skip to content

Commit 38766ec

Browse files
VanderClawVanderClaw
authored andcommitted
fix: prevent prose-only agent stalls
1 parent 008a15d commit 38766ec

10 files changed

Lines changed: 612 additions & 24 deletions

File tree

‎docs/configuration.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
4848
| `permissions` | object | 权限策略及 `addWorkingDirs` 额外工作目录配置(参见 [permission.md](./permission.md)) |
4949
| `enabledSkills` | object | 按 skill 名称启用或禁用 skill 的配置 |
5050
| `statusline` | object | 状态栏插件配置(参见 [statusline.md](./statusline.md)) |
51+
| `intentNarrationGuard` | object | 拒绝只有执行意图、没有工具调用的回合,并限制重复停滞(默认启用) |
5152

5253
#### `env` 子字段
5354

@@ -62,6 +63,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
6263
| `MULTIMODAL` | string | 多模态(图片)能力开关,可选 `"default"`、`"on"` 或 `"off"` |
6364
| `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 |
6465
| `TELEMETRY_ENABLED` | string | 是否启用匿名使用数据上报 |
66+
| `INTENT_NARRATION_GUARD_ENABLED` | string | 是否启用执行意图防停滞保护 |
6567
| `<其他任意KEY>` | string | 自定义环境变量 |
6668

6769
#### 上下文窗口
@@ -176,6 +178,27 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
176178
- 将某个 skill 设置为 `false` 后,所有项目级和用户级目录中解析名称相同的 skill 都会被隐藏。
177179
- 项目设置会按 skill 覆盖用户设置。如果项目设置没有配置某个 skill,则使用用户设置。
178180

181+
#### `intentNarrationGuard` — 纯意图文本防停滞保护
182+
183+
Deep Code 每个模型步骤最多执行一个工具调用。如果模型返回了已识别的执行意图短语,却没有工具调用,该回合会被丢弃并替换为简短的系统纠正指令。包含真实工具调用的文本回合不受影响。默认情况下,最近六个模型回合中出现四个被拒绝回合时,运行会明确失败,避免无限循环。
184+
185+
```json
186+
{
187+
"intentNarrationGuard": {
188+
"enabled": true,
189+
"additionalPhrases": ["马上调用"],
190+
"instruction": "No prose intent. Emit the tool call now.",
191+
"hardStopRejections": 4,
192+
"hardStopWindow": 6
193+
}
194+
}
195+
```
196+
197+
- `phrases` 替换内置短语列表;`additionalPhrases` 在内置列表上扩展。
198+
- 将 `hardStopRejections` 设为 `0` 仅关闭硬停止上限。
199+
- 每次拒绝都会累加 `SessionEntry.intentNarrationRejections`,并在 `~/.deepcode/logs/intent-narration.log` 中记录步骤 ID、文本 SHA-256 哈希和截断预览。
200+
- 可设置 `DEEPCODE_INTENT_NARRATION_GUARD_ENABLED=false`,在不修改设置文件的情况下为当前进程关闭保护。
201+
179202
#### `mcpServers` — MCP 服务器
180203

181204
MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。

‎docs/configuration_en.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ The following are all the top-level fields supported in `settings.json`, along w
4848
| `permissions` | object | Permission policy and additional `addWorkingDirs` workspace roots (see [permission_en.md](./permission_en.md)) |
4949
| `enabledSkills` | object | Per-skill enable/disable map, keyed by skill name |
5050
| `statusline` | object | Status line plugins (see [statusline_en.md](./statusline_en.md)) |
51+
| `intentNarrationGuard` | object | Reject prose-only intent turns and cap repeated stalls (enabled by default) |
5152

5253
#### `env` Sub-fields
5354

@@ -62,6 +63,7 @@ The following are all the top-level fields supported in `settings.json`, along w
6263
| `MULTIMODAL` | string | Multimodal (image) capability override: `"default"`, `"on"`, or `"off"` |
6364
| `DEBUG_LOG_ENABLED`| string| Enable debug log output |
6465
| `TELEMETRY_ENABLED`| string| Enable anonymous usage reporting |
66+
| `INTENT_NARRATION_GUARD_ENABLED` | string | Enable or disable the intent narration guard |
6567
| `<any other KEY>` | string | Custom environment variable |
6668

6769
#### Context Windows
@@ -176,6 +178,27 @@ Controls whether skills are included during skill scanning. Keys are resolved sk
176178
- Setting a skill to `false` hides every skill with that resolved `name`, across project and user skill roots.
177179
- Project settings override user settings per skill. If the project setting omits a skill, the user setting is used.
178180

181+
#### `intentNarrationGuard` — Prose-only Stall Protection
182+
183+
Deep Code enforces one tool call per model step. If a model returns a recognized intent phrase without a tool call, the turn is discarded and replaced with a short corrective system instruction. Prose accompanied by a real tool call passes unchanged. By default, four rejected turns in the last six model turns fail the run instead of allowing an unbounded loop.
184+
185+
```json
186+
{
187+
"intentNarrationGuard": {
188+
"enabled": true,
189+
"additionalPhrases": ["about to invoke"],
190+
"instruction": "No prose intent. Emit the tool call now.",
191+
"hardStopRejections": 4,
192+
"hardStopWindow": 6
193+
}
194+
}
195+
```
196+
197+
- `phrases` replaces the built-in phrase list; `additionalPhrases` extends it.
198+
- Set `hardStopRejections` to `0` to disable only the hard cap.
199+
- Rejections increment `SessionEntry.intentNarrationRejections` and are logged to `~/.deepcode/logs/intent-narration.log` with the step ID, a SHA-256 text hash, and a truncated preview.
200+
- `DEEPCODE_INTENT_NARRATION_GUARD_ENABLED=false` disables the guard for a process without editing settings files.
201+
179202
#### `mcpServers` — MCP Servers
180203

181204
Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object.

‎packages/cli/src/tests/exec-runner.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ function createSettings(
4242
permissions,
4343
enabledSkills: {},
4444
statusline: { enabled: false, refreshMs: 1000, separator: " | ", providers: [] },
45+
intentNarrationGuard: {
46+
enabled: true,
47+
phrases: ["let me run"],
48+
instruction: "No prose intent. Emit the tool call now.",
49+
hardStopRejections: 4,
50+
hardStopWindow: 6,
51+
},
4552
};
4653
}
4754

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as crypto from "node:crypto";
2+
import * as fs from "node:fs";
3+
import * as os from "node:os";
4+
import * as path from "node:path";
5+
6+
const INTENT_NARRATION_LOG_FILE = "intent-narration.log";
7+
const LOG_PREVIEW_LENGTH = 160;
8+
9+
export const DEFAULT_INTENT_NARRATION_PHRASES = [
10+
"let me run",
11+
"let me just",
12+
"let me execute",
13+
"I'll run it now",
14+
"I'll just run",
15+
"I'll just call",
16+
"running it now",
17+
"I'm going to run",
18+
"I will run it now",
19+
"doing it now",
20+
"executing now",
21+
"calling it now",
22+
"invoking now",
23+
"I'm running it",
24+
"for real",
25+
"no more loops",
26+
] as const;
27+
28+
export const DEFAULT_INTENT_NARRATION_INSTRUCTION = "No prose intent. Emit the tool call now.";
29+
30+
export type IntentNarrationGuardSettings = {
31+
enabled?: boolean;
32+
phrases?: string[];
33+
additionalPhrases?: string[];
34+
instruction?: string;
35+
hardStopRejections?: number;
36+
hardStopWindow?: number;
37+
};
38+
39+
export type ResolvedIntentNarrationGuardSettings = {
40+
enabled: boolean;
41+
phrases: string[];
42+
instruction: string;
43+
hardStopRejections: number;
44+
hardStopWindow: number;
45+
};
46+
47+
export const DEFAULT_INTENT_NARRATION_GUARD_SETTINGS: ResolvedIntentNarrationGuardSettings = {
48+
enabled: true,
49+
phrases: [...DEFAULT_INTENT_NARRATION_PHRASES],
50+
instruction: DEFAULT_INTENT_NARRATION_INSTRUCTION,
51+
hardStopRejections: 4,
52+
hardStopWindow: 6,
53+
};
54+
55+
export type IntentNarrationRejectionEvent = {
56+
timestamp: string;
57+
sessionId: string;
58+
stepId: string;
59+
matchedPhrase: string;
60+
textHash: string;
61+
textPreview: string;
62+
totalRejections: number;
63+
windowRejections: number;
64+
windowSize: number;
65+
hardStopped: boolean;
66+
};
67+
68+
export function findIntentNarrationPhrase(
69+
content: string,
70+
hasToolCall: boolean,
71+
settings: ResolvedIntentNarrationGuardSettings
72+
): string | null {
73+
if (!settings.enabled || hasToolCall) {
74+
return null;
75+
}
76+
77+
const normalizedContent = normalizeForMatching(content);
78+
if (!normalizedContent) {
79+
return null;
80+
}
81+
82+
for (const phrase of settings.phrases) {
83+
const normalizedPhrase = normalizeForMatching(phrase);
84+
if (normalizedPhrase && normalizedContent.includes(normalizedPhrase)) {
85+
return phrase;
86+
}
87+
}
88+
return null;
89+
}
90+
91+
export function recordRejectionInWindow(history: boolean[], rejected: boolean, windowSize: number): boolean[] {
92+
const boundedWindow = Math.max(1, Math.floor(windowSize));
93+
return [...history, rejected].slice(-boundedWindow);
94+
}
95+
96+
export function shouldHardStopIntentNarration(
97+
history: boolean[],
98+
settings: ResolvedIntentNarrationGuardSettings
99+
): boolean {
100+
if (settings.hardStopRejections <= 0) {
101+
return false;
102+
}
103+
return history.filter(Boolean).length >= settings.hardStopRejections;
104+
}
105+
106+
export function createIntentNarrationRejectionEvent(input: {
107+
content: string;
108+
sessionId: string;
109+
stepId: string;
110+
matchedPhrase: string;
111+
totalRejections: number;
112+
rejectionHistory: boolean[];
113+
windowSize: number;
114+
hardStopped: boolean;
115+
}): IntentNarrationRejectionEvent {
116+
const normalizedPreview = input.content.replace(/\s+/g, " ").trim();
117+
return {
118+
timestamp: new Date().toISOString(),
119+
sessionId: input.sessionId,
120+
stepId: input.stepId,
121+
matchedPhrase: input.matchedPhrase,
122+
textHash: `sha256:${crypto.createHash("sha256").update(input.content).digest("hex")}`,
123+
textPreview:
124+
normalizedPreview.length > LOG_PREVIEW_LENGTH
125+
? `${normalizedPreview.slice(0, LOG_PREVIEW_LENGTH)}…`
126+
: normalizedPreview,
127+
totalRejections: input.totalRejections,
128+
windowRejections: input.rejectionHistory.filter(Boolean).length,
129+
windowSize: input.windowSize,
130+
hardStopped: input.hardStopped,
131+
};
132+
}
133+
134+
export function logIntentNarrationRejection(event: IntentNarrationRejectionEvent): void {
135+
try {
136+
const logPath = getIntentNarrationLogPath();
137+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
138+
fs.appendFileSync(logPath, `${JSON.stringify(event)}\n`, "utf8");
139+
} catch {
140+
// Guard diagnostics must never change agent-loop behavior.
141+
}
142+
}
143+
144+
export function getIntentNarrationLogPath(): string {
145+
return path.join(os.homedir(), ".deepcode", "logs", INTENT_NARRATION_LOG_FILE);
146+
}
147+
148+
function normalizeForMatching(value: string): string {
149+
return value
150+
.normalize("NFKC")
151+
.replace(/[\u2018\u2019\u02bc]/g, "'")
152+
.replace(/\s+/g, " ")
153+
.trim()
154+
.toLowerCase();
155+
}

‎packages/core/src/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ export type {
3939
ResolvedStatusLineSettings,
4040
StatusLineProviderConfig,
4141
} from "./settings";
42+
export type {
43+
IntentNarrationGuardSettings,
44+
IntentNarrationRejectionEvent,
45+
ResolvedIntentNarrationGuardSettings,
46+
} from "./common/intent-narration-guard";
47+
export {
48+
DEFAULT_INTENT_NARRATION_GUARD_SETTINGS,
49+
DEFAULT_INTENT_NARRATION_INSTRUCTION,
50+
DEFAULT_INTENT_NARRATION_PHRASES,
51+
} from "./common/intent-narration-guard";
4252

4353
// Session
4454
export { SessionManager, getProjectCode, getCompactPromptTokenThreshold } from "./session";

0 commit comments

Comments
 (0)