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

#### `env` 子字段

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

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

#### `intentNarrationGuard` — 纯意图文本防停滞保护

Deep Code 每个模型步骤最多执行一个工具调用。如果模型返回了已识别的执行意图短语,却没有工具调用,该回合会被丢弃并替换为简短的系统纠正指令。包含真实工具调用的文本回合不受影响。默认情况下,最近六个模型回合中出现四个被拒绝回合时,运行会明确失败,避免无限循环。

```json
{
"intentNarrationGuard": {
"enabled": true,
"additionalPhrases": ["马上调用"],
"instruction": "No prose intent. Emit the tool call now.",
"hardStopRejections": 4,
"hardStopWindow": 6
}
}
```

- `phrases` 替换内置短语列表;`additionalPhrases` 在内置列表上扩展。
- 将 `hardStopRejections` 设为 `0` 仅关闭硬停止上限。
- 每次拒绝都会累加 `SessionEntry.intentNarrationRejections`,并在 `~/.deepcode/logs/intent-narration.log` 中记录步骤 ID、文本 SHA-256 哈希和截断预览。
- 可设置 `DEEPCODE_INTENT_NARRATION_GUARD_ENABLED=false`,在不修改设置文件的情况下为当前进程关闭保护。

#### `mcpServers` — MCP 服务器

MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。
Expand Down
23 changes: 23 additions & 0 deletions docs/configuration_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ The following are all the top-level fields supported in `settings.json`, along w
| `permissions` | object | Permission policy and additional `addWorkingDirs` workspace roots (see [permission_en.md](./permission_en.md)) |
| `enabledSkills` | object | Per-skill enable/disable map, keyed by skill name |
| `statusline` | object | Status line plugins (see [statusline_en.md](./statusline_en.md)) |
| `intentNarrationGuard` | object | Reject prose-only intent turns and cap repeated stalls (enabled by default) |

#### `env` Sub-fields

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

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

#### `intentNarrationGuard` — Prose-only Stall Protection

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.

```json
{
"intentNarrationGuard": {
"enabled": true,
"additionalPhrases": ["about to invoke"],
"instruction": "No prose intent. Emit the tool call now.",
"hardStopRejections": 4,
"hardStopWindow": 6
}
}
```

- `phrases` replaces the built-in phrase list; `additionalPhrases` extends it.
- Set `hardStopRejections` to `0` to disable only the hard cap.
- 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.
- `DEEPCODE_INTENT_NARRATION_GUARD_ENABLED=false` disables the guard for a process without editing settings files.

#### `mcpServers` — MCP Servers

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.
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/tests/exec-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ function createSettings(
permissions,
enabledSkills: {},
statusline: { enabled: false, refreshMs: 1000, separator: " | ", providers: [] },
intentNarrationGuard: {
enabled: true,
phrases: ["let me run"],
instruction: "No prose intent. Emit the tool call now.",
hardStopRejections: 4,
hardStopWindow: 6,
},
};
}

Expand Down
185 changes: 185 additions & 0 deletions packages/core/src/common/intent-narration-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

const INTENT_NARRATION_LOG_FILE = "intent-narration.log";
const LOG_PREVIEW_LENGTH = 160;

export const DEFAULT_INTENT_NARRATION_PHRASES = [
"let me run",
"let me just",
"let me execute",
"let me add",
"let me apply",
"let me build",
"let me check",
"let me close",
"let me commit",
"let me continue",
"let me create",
"let me deploy",
"let me edit",
"let me fetch",
"let me fix",
"let me implement",
"let me inspect",
"let me install",
"let me invoke",
"let me mark",
"let me merge",
"let me open",
"let me patch",
"let me port",
"let me proceed",
"let me push",
"let me read",
"let me restart",
"let me set up",
"let me test",
"let me update",
"let me verify",
"let me write",
"I'll run it now",
"I'll just run",
"I'll just call",
"running it now",
"I'm going to run",
"I will run it now",
"doing it now",
"executing now",
"calling it now",
"invoking now",
"I'm running it",
"for real",
"no more loops",
] as const;

export const DEFAULT_INTENT_NARRATION_INSTRUCTION = "No prose intent. Emit the tool call now.";

export type IntentNarrationGuardSettings = {
enabled?: boolean;
phrases?: string[];
additionalPhrases?: string[];
instruction?: string;
hardStopRejections?: number;
hardStopWindow?: number;
};

export type ResolvedIntentNarrationGuardSettings = {
enabled: boolean;
phrases: string[];
instruction: string;
hardStopRejections: number;
hardStopWindow: number;
};

export const DEFAULT_INTENT_NARRATION_GUARD_SETTINGS: ResolvedIntentNarrationGuardSettings = {
enabled: true,
phrases: [...DEFAULT_INTENT_NARRATION_PHRASES],
instruction: DEFAULT_INTENT_NARRATION_INSTRUCTION,
hardStopRejections: 4,
hardStopWindow: 6,
};

export type IntentNarrationRejectionEvent = {
timestamp: string;
sessionId: string;
stepId: string;
matchedPhrase: string;
textHash: string;
textPreview: string;
totalRejections: number;
windowRejections: number;
windowSize: number;
hardStopped: boolean;
};

export function findIntentNarrationPhrase(
content: string,
hasToolCall: boolean,
settings: ResolvedIntentNarrationGuardSettings
): string | null {
if (!settings.enabled || hasToolCall) {
return null;
}

const normalizedContent = normalizeForMatching(content);
if (!normalizedContent) {
return null;
}

for (const phrase of settings.phrases) {
const normalizedPhrase = normalizeForMatching(phrase);
if (normalizedPhrase && normalizedContent.includes(normalizedPhrase)) {
return phrase;
}
}
return null;
}

export function recordRejectionInWindow(history: boolean[], rejected: boolean, windowSize: number): boolean[] {
const boundedWindow = Math.max(1, Math.floor(windowSize));
return [...history, rejected].slice(-boundedWindow);
}

export function shouldHardStopIntentNarration(
history: boolean[],
settings: ResolvedIntentNarrationGuardSettings
): boolean {
if (settings.hardStopRejections <= 0) {
return false;
}
return history.filter(Boolean).length >= settings.hardStopRejections;
}

export function createIntentNarrationRejectionEvent(input: {
content: string;
sessionId: string;
stepId: string;
matchedPhrase: string;
totalRejections: number;
rejectionHistory: boolean[];
windowSize: number;
hardStopped: boolean;
}): IntentNarrationRejectionEvent {
const normalizedPreview = input.content.replace(/\s+/g, " ").trim();
return {
timestamp: new Date().toISOString(),
sessionId: input.sessionId,
stepId: input.stepId,
matchedPhrase: input.matchedPhrase,
textHash: `sha256:${crypto.createHash("sha256").update(input.content).digest("hex")}`,
textPreview:
normalizedPreview.length > LOG_PREVIEW_LENGTH
? `${normalizedPreview.slice(0, LOG_PREVIEW_LENGTH)}…`
: normalizedPreview,
totalRejections: input.totalRejections,
windowRejections: input.rejectionHistory.filter(Boolean).length,
windowSize: input.windowSize,
hardStopped: input.hardStopped,
};
}

export function logIntentNarrationRejection(event: IntentNarrationRejectionEvent): void {
try {
const logPath = getIntentNarrationLogPath();
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.appendFileSync(logPath, `${JSON.stringify(event)}\n`, "utf8");
} catch {
// Guard diagnostics must never change agent-loop behavior.
}
}

export function getIntentNarrationLogPath(): string {
return path.join(os.homedir(), ".deepcode", "logs", INTENT_NARRATION_LOG_FILE);
}

function normalizeForMatching(value: string): string {
return value
.normalize("NFKC")
.replace(/[\u2018\u2019\u02bc]/g, "'")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
18 changes: 16 additions & 2 deletions packages/core/src/common/llm-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getLlmErrorDetails } from "./llm-error";

export const MAX_LLM_RETRIES = 5;
export const LLM_STREAM_IDLE_TIMEOUT_MS = 60_000;
export const LLM_STREAM_FIRST_CHUNK_TIMEOUT_MS = 300_000;

const BASE_RETRY_DELAY_MS = 800;
const RETRYABLE_NETWORK_CODES = new Set([
Expand All @@ -18,12 +19,19 @@ const RETRYABLE_NETWORK_CODES = new Set([
]);

export class LlmStreamIdleTimeoutError extends Error {
constructor() {
super(`Model stream was idle for ${LLM_STREAM_IDLE_TIMEOUT_MS / 1000} seconds.`);
constructor(timeoutMs: number = LLM_STREAM_IDLE_TIMEOUT_MS) {
super(`Model stream was idle for ${timeoutMs / 1000} seconds.`);
this.name = "LlmStreamIdleTimeoutError";
}
}

export class LlmStreamFirstChunkTimeoutError extends Error {
constructor(timeoutMs: number = LLM_STREAM_FIRST_CHUNK_TIMEOUT_MS) {
super(`Model stream produced no first chunk for ${timeoutMs / 1000} seconds.`);
this.name = "LlmStreamFirstChunkTimeoutError";
}
}

export class LlmStreamDisconnectedError extends Error {
constructor() {
super("Model stream disconnected before completion.");
Expand Down Expand Up @@ -57,6 +65,12 @@ export function getLlmRetryAfterMs(error: unknown, now: number = Date.now()): nu
}

export function isRetryableLlmError(error: unknown): boolean {
// A first-chunk timeout commonly means an expensive local-model prefill is
// still running. Replaying the same large prompt multiplies work without
// improving recovery, so fail once and let route/fallback policy take over.
if (error instanceof LlmStreamFirstChunkTimeoutError) {
return false;
}
if (error instanceof LlmStreamIdleTimeoutError || error instanceof LlmStreamDisconnectedError) {
return true;
}
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export type {
ResolvedStatusLineSettings,
StatusLineProviderConfig,
} from "./settings";
export type {
IntentNarrationGuardSettings,
IntentNarrationRejectionEvent,
ResolvedIntentNarrationGuardSettings,
} from "./common/intent-narration-guard";
export {
DEFAULT_INTENT_NARRATION_GUARD_SETTINGS,
DEFAULT_INTENT_NARRATION_INSTRUCTION,
DEFAULT_INTENT_NARRATION_PHRASES,
} from "./common/intent-narration-guard";

// Session
export { SessionManager, getProjectCode, getCompactPromptTokenThreshold } from "./session";
Expand Down
Loading