Skip to content
Open
6 changes: 6 additions & 0 deletions src/common/utils/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import type { WorkspaceChatMessage } from "@/common/orpc/types";
import type { FileState } from "@/node/services/agentSession";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import type { AgentSkillDescriptor } from "@/common/types/agentSkill";
import type { StreamEditTracker } from "@/node/services/streamGuardrails/StreamEditTracker";
import type { StreamVerificationTracker } from "@/node/services/streamGuardrails/StreamVerificationTracker";

/**
* Configuration for tools that need runtime context
Expand Down Expand Up @@ -80,6 +82,10 @@ export interface ToolConfiguration {
taskService?: TaskService;
/** Enable agent_report tool (only valid for child task workspaces) */
enableAgentReport?: boolean;
/** Per-stream edit tracker for doom-loop detection (not set for IPC tool calls) */
editTracker?: StreamEditTracker;
/** Per-stream verification tracker for completion guard (not set for IPC tool calls) */
verificationTracker?: StreamVerificationTracker;
/** Experiments inherited from parent (for subagent spawning) */
experiments?: {
programmaticToolCalling?: boolean;
Expand Down
9 changes: 9 additions & 0 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import {
} from "./streamSimulation";
import { applyToolPolicyAndExperiments, captureMcpToolTelemetry } from "./toolAssembly";
import { getErrorMessage } from "@/common/utils/errors";
import { StreamEditTracker } from "./streamGuardrails/StreamEditTracker";
import { StreamVerificationTracker } from "./streamGuardrails/StreamVerificationTracker";

// ---------------------------------------------------------------------------
// streamMessage options
Expand Down Expand Up @@ -699,6 +701,10 @@ export class AIService extends EventEmitter {
}
}

// Guardrail trackers are per-stream and only enabled for AI tool execution.
const editTracker = new StreamEditTracker();
const verificationTracker = new StreamVerificationTracker();

// Get model-specific tools with workspace path (correct for local or remote)
const allTools = await getToolsForModel(
modelString,
Expand Down Expand Up @@ -734,6 +740,9 @@ export class AIService extends EventEmitter {
workspaceId,
// Only child workspaces (tasks) can report to a parent.
enableAgentReport: Boolean(metadata.parentWorkspaceId),
// Per-stream deterministic guardrails for completion + doom-loop detection.
editTracker,
verificationTracker,
// External edit detection callback
recordFileState,
taskService: this.taskService,
Expand Down
64 changes: 64 additions & 0 deletions src/node/services/streamGuardrails/StreamEditTracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";

import { DOOM_LOOP_EDIT_THRESHOLD, StreamEditTracker } from "./StreamEditTracker";

describe("StreamEditTracker", () => {
test("recordEdit increments edit count for the same file", () => {
const tracker = new StreamEditTracker();

expect(tracker.recordEdit("/tmp/file.ts")).toBe(1);
expect(tracker.recordEdit("/tmp/file.ts")).toBe(2);
expect(tracker.recordEdit("/tmp/file.ts")).toBe(3);
});

test("hasAnyEdits is false before edits and true after first edit", () => {
const tracker = new StreamEditTracker();

expect(tracker.hasAnyEdits()).toBe(false);
tracker.recordEdit("/tmp/file.ts");
expect(tracker.hasAnyEdits()).toBe(true);
});

test("shouldNudge is false below threshold and true at threshold", () => {
const tracker = new StreamEditTracker();
const filePath = "/tmp/file.ts";

for (let i = 0; i < DOOM_LOOP_EDIT_THRESHOLD - 1; i += 1) {
tracker.recordEdit(filePath);
}

expect(tracker.shouldNudge(filePath, DOOM_LOOP_EDIT_THRESHOLD)).toBe(false);

tracker.recordEdit(filePath);
expect(tracker.shouldNudge(filePath, DOOM_LOOP_EDIT_THRESHOLD)).toBe(true);
});

test("shouldNudge is once per file after markNudged", () => {
const tracker = new StreamEditTracker();
const filePath = "/tmp/file.ts";

for (let i = 0; i < DOOM_LOOP_EDIT_THRESHOLD; i += 1) {
tracker.recordEdit(filePath);
}

expect(tracker.shouldNudge(filePath, DOOM_LOOP_EDIT_THRESHOLD)).toBe(true);

tracker.markNudged(filePath);
expect(tracker.shouldNudge(filePath, DOOM_LOOP_EDIT_THRESHOLD)).toBe(false);

tracker.recordEdit(filePath);
expect(tracker.shouldNudge(filePath, DOOM_LOOP_EDIT_THRESHOLD)).toBe(false);
});

test("tracks edit counts independently per file", () => {
const tracker = new StreamEditTracker();

for (let i = 0; i < DOOM_LOOP_EDIT_THRESHOLD; i += 1) {
tracker.recordEdit("/tmp/a.ts");
}
tracker.recordEdit("/tmp/b.ts");

expect(tracker.shouldNudge("/tmp/a.ts", DOOM_LOOP_EDIT_THRESHOLD)).toBe(true);
expect(tracker.shouldNudge("/tmp/b.ts", DOOM_LOOP_EDIT_THRESHOLD)).toBe(false);
});
});
45 changes: 45 additions & 0 deletions src/node/services/streamGuardrails/StreamEditTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "@/common/utils/assert";

export const DOOM_LOOP_EDIT_THRESHOLD = 7;

/**
* Tracks file edit frequency for a single stream to detect potential doom loops.
*/
export class StreamEditTracker {
private readonly editCountsByFile = new Map<string, number>();
private readonly nudgedFiles = new Set<string>();

recordEdit(filePath: string): number {
assert(
typeof filePath === "string" && filePath.length > 0,
"filePath must be a non-empty string"
);

const nextCount = (this.editCountsByFile.get(filePath) ?? 0) + 1;
this.editCountsByFile.set(filePath, nextCount);
return nextCount;
}

hasAnyEdits(): boolean {
return this.editCountsByFile.size > 0;
}

shouldNudge(filePath: string, threshold: number): boolean {
assert(
typeof filePath === "string" && filePath.length > 0,
"filePath must be a non-empty string"
);
assert(Number.isFinite(threshold) && threshold > 0, "threshold must be a positive number");

const editCount = this.editCountsByFile.get(filePath) ?? 0;
return editCount >= threshold && !this.nudgedFiles.has(filePath);
}

markNudged(filePath: string): void {
assert(
typeof filePath === "string" && filePath.length > 0,
"filePath must be a non-empty string"
);
this.nudgedFiles.add(filePath);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";

import { StreamVerificationTracker } from "./StreamVerificationTracker";

describe("StreamVerificationTracker", () => {
test("hasValidationAttempt is false initially and true after markValidationAttempt", () => {
const tracker = new StreamVerificationTracker();

expect(tracker.hasValidationAttempt()).toBe(false);

tracker.markValidationAttempt();
expect(tracker.hasValidationAttempt()).toBe(true);
});

test("nudge lifecycle for completion guard", () => {
const tracker = new StreamVerificationTracker();

expect(tracker.hasBeenNudged()).toBe(false);
expect(tracker.shouldNudgeBeforeAllowingReport(false)).toBe(false);
expect(tracker.shouldNudgeBeforeAllowingReport(true)).toBe(true);

tracker.markNudged();
expect(tracker.hasBeenNudged()).toBe(true);
expect(tracker.shouldNudgeBeforeAllowingReport(true)).toBe(false);

tracker.markValidationAttempt();
expect(tracker.hasValidationAttempt()).toBe(true);
expect(tracker.shouldNudgeBeforeAllowingReport(true)).toBe(false);
});

test("resetValidation clears validation state so pre-edit validation doesn't count", () => {
const tracker = new StreamVerificationTracker();

// Validate first
tracker.markValidationAttempt();
expect(tracker.hasValidationAttempt()).toBe(true);

// Then an edit happens β€” validation should be reset
tracker.resetValidation();
expect(tracker.hasValidationAttempt()).toBe(false);

// Guard should now nudge because validation was pre-edit
expect(tracker.shouldNudgeBeforeAllowingReport(true)).toBe(true);
});

test("post-edit validation still counts after reset", () => {
const tracker = new StreamVerificationTracker();

// Validate, then edit resets it
tracker.markValidationAttempt();
tracker.resetValidation();
expect(tracker.hasValidationAttempt()).toBe(false);

// Validate again (post-edit) β€” should count
tracker.markValidationAttempt();
expect(tracker.hasValidationAttempt()).toBe(true);
expect(tracker.shouldNudgeBeforeAllowingReport(true)).toBe(false);
});
});
36 changes: 36 additions & 0 deletions src/node/services/streamGuardrails/StreamVerificationTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Tracks whether a stream attempted validation commands before completion.
*
* Validation state is reset whenever a file edit is recorded, so only
* post-edit validation counts towards the pre-completion guard.
*/
export class StreamVerificationTracker {
private validationAttempted = false;
private nudgedBeforeReport = false;

markValidationAttempt(): void {
this.validationAttempted = true;
}

/** Reset validation state β€” called when new edits are recorded so that
* pre-edit validation doesn't satisfy the post-edit verification guard. */
resetValidation(): void {
this.validationAttempted = false;
}

hasValidationAttempt(): boolean {
return this.validationAttempted;
}

hasBeenNudged(): boolean {
return this.nudgedBeforeReport;
}

markNudged(): void {
this.nudgedBeforeReport = true;
}

shouldNudgeBeforeAllowingReport(hasEdits: boolean): boolean {
return hasEdits && !this.validationAttempted && !this.nudgedBeforeReport;
}
}
Loading
Loading