Skip to content
15 changes: 1 addition & 14 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,7 @@ Autonomous coding agents frequently enter degenerate loops:
- Repeated searches
- Verification cycles with no changes

Acolyte uses behavioral guards that run before every tool call.

| Guard id | Purpose |
|---|---|
| `circuit-breaker` | Stop execution after too many consecutive blocked calls |
| `step-budget` | Enforce per-cycle and total step budgets |
| `duplicate-call` | Block near-duplicate tool calls with no state change in between |
| `ping-pong` | Block alternating tool call patterns indicating the model is stuck |
| `stale-result` | Block calls that repeatedly return the same result |
| `file-churn` | Detect read/edit loops against the same file |
| `redundant-find` | Block narrower file discovery after broader calls |
| `redundant-search` | Block redundant search-files calls |
| `redundant-verify` | Prevent verify when no write tools ran |
| `post-edit-redundancy` | Block redundant follow-up edits without fresh file evidence |
Acolyte uses behavioral guards that run before every tool call. Guards cover step budgets, duplicate/redundant calls, file churn, ping-pong loops, and lifecycle command enforcement. See `src/tool-guards.ts` for the full set.

Only OpenClaw and OpenHands ship comparable runtime safeguards.

Expand Down
2 changes: 1 addition & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Naming conventions and core terms used across Acolyte code and docs.
| Ecosystem Detector | Pluggable workspace detection rule that identifies project type and resolves available tooling |
| Entry | Runtime/pipeline item used during processing; not necessarily persisted |
| Evaluator | Post-generation rule that accepts or requests regeneration |
| Guard | Pre-tool execution rule that may block calls (step budget, file churn, duplicate call, redundant search/find/verify) |
| Guard | Pre-tool execution rule that may block calls; see `src/tool-guards.ts` for the full set |
| Host | The runtime environment around the model that provides tools, lifecycle structure, memory, guards, and recovery behavior |
| Lifecycle Feedback | Task-scoped runtime feedback emitted by evaluators or selected guard outcomes and consumed by the next matching lifecycle attempt |
| Lifecycle Policy | Bounded execution controls for lifecycle behavior (timeouts, regeneration caps) |
Expand Down
2 changes: 0 additions & 2 deletions src/agent-instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ describe("createModeInstructions", () => {
expect(out).toContain("repeated literal replacements in one known file");
expect(out).toContain("collect every visible requested occurrence");
expect(out).toContain("must cover all of those visible locations");
expect(out).toContain("If a direct `read-file` result is truncated");
expect(out).toContain("if a named file has separated occurrences you have not yet pinned to exact snippets");
expect(out).toContain("do not signal completion after the first hit or first partial batch");
expect(out).toContain("make the requested change and stop");
Expand Down Expand Up @@ -99,7 +98,6 @@ describe("createInstructions", () => {
expect(out).toContain('{ op: "replace", rule: { all: [{ kind: "call_expression" }');
expect(out).toContain("broadening the rewrite to unrelated matches");
expect(out).toContain("calling another write tool on that same file");
expect(out).toContain("do not re-read the same file unless the edit fails or the direct read output was truncated");
expect(out).toContain("If that preview shows the requested bounded change, stop");
expect(out).toContain("stop instead of re-reading, searching, reviewing, or editing that same file again");
expect(out).toContain("use several small exact edits in one call rather than one oversized `find` block");
Expand Down
4 changes: 1 addition & 3 deletions src/agent-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export const agentModes: Record<AgentMode, AgentModeDefinition> = {
tools: toolIdsForGrants(["read", "write", "execute", "network"]),
preamble: [
"If the target path is explicit, skip `find-files`/`search-files` and read that file directly.",
"Always read the full file without line ranges unless you know the file is very large.",
"For 'add/update in file X' tasks, make `read-file` on X your first tool call.",
"If the user names the files to change, limit reads and edits to those files plus directly referenced support files needed to complete the task.",
"For explicit multi-file edit tasks, work one named file at a time: read the file you are about to change, edit it, then move to the next.",
Expand All @@ -27,7 +26,6 @@ export const agentModes: Record<AgentMode, AgentModeDefinition> = {
"For small fixes in an existing file, use exact `find`/`replace` edits and keep the change as small as the request allows.",
"For repeated literal replacements in one known file, do not use `search-files`, `scan-code`, or extra rereads after the initial direct read. Use that read to collect every visible requested occurrence and make one consolidated `edit-file` call.",
"If the requested literal appears in multiple visible locations in the direct read of a named file, your `edit-file` call must cover all of those visible locations, not just the first contiguous block.",
"If a direct `read-file` result is truncated or omits part of a named file, gather the missing ranges with bounded `read-file` calls before finishing a repeated replacement task on that file.",
"For multi-file rename or repeated replacement tasks, if a named file has separated occurrences you have not yet pinned to exact snippets, run one scoped `search-files` on that file before editing instead of guessing a larger `find` block.",
"For bounded 'each'/'every'/'all' replacements in one named file, do not signal completion after the first hit or first partial batch; finish only when the latest file text and edit preview show no remaining requested matches in that file.",
"For explicit bounded fixes, make the requested change and stop.",
Expand All @@ -44,7 +42,7 @@ export const agentModes: Record<AgentMode, AgentModeDefinition> = {
"Trust type signatures; do not add impossible null/undefined guards unless the declared types allow them.",
"Never delete a file to recreate it — use `edit-file` to modify existing files.",
"When a target file does not exist, say so instead of silently creating it.",
"Do not run verify, test, or build commands — the lifecycle handles format, lint, and verify automatically after your edits.",
"Do not run lint, format, build, or verify commands — the lifecycle runs them automatically after your edits. If you added or changed a test file, run only that specific test file, not the full test suite.",
"Do not signal done until the requested behavior is actually implemented. Updating help text, comments, or tests alone is not completing the task — the functional change must be in place.",
"After the last tool call, use the lifecycle signal format from the base instructions and keep the user-facing outcome to one sentence.",
"For multi-step tasks (3+ distinct steps), use `create-checklist` at the start to define a progress checklist. Use `update-checklist` to mark items as you complete each step.",
Expand Down
2 changes: 1 addition & 1 deletion src/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const appConfig = {
searchFiles: { maxChars: 2200, maxLines: 80 },
webSearch: { maxChars: 2400, maxLines: 80 },
webFetch: { maxChars: 2600, maxLines: 90 },
read: { maxChars: 2600, maxLines: 120 },
read: { maxChars: 80_000, maxLines: 2000 },
gitStatus: { maxChars: 1800, maxLines: 80 },
gitDiff: { maxChars: 3200, maxLines: 120 },
run: { maxChars: 2600, maxLines: 120 },
Expand Down
30 changes: 30 additions & 0 deletions src/chat-promotion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,36 @@ describe("usePromotion hook", () => {
unmount();
});

test("clearTranscript replaces header without duplicating", async () => {
const session = createSession({ id: "sess_clear1" });
const { result, unmount } = renderHook(() =>
usePromotion({
version: "1.0",
session,
currentSessionId: session.id,
rowsRef: { current: [] },
setRows: () => {},
}),
);

expect(result.current.promotedRows.filter((r) => r.id === "header_sess_clear1")).toHaveLength(1);

result.current.clearTranscript();
await wait();

const headers = result.current.promotedRows.filter((r) => r.kind === "header");
// Original header + new header after clear (unique IDs, no duplicates)
expect(headers).toHaveLength(2);
expect(headers[0]?.id).toBe("header_sess_clear1");
expect(headers[1]?.id).toMatch(/^header_sess_clear1_/);

// No duplicate key warnings — all IDs are unique
const ids = result.current.promotedRows.map((r) => r.id);
expect(new Set(ids).size).toBe(ids.length);

unmount();
});

test("promote moves live rows to promoted", async () => {
const session = createSession({ id: "sess_p1" });
const liveRows: ChatRow[] = [{ id: "row_1", kind: "user", content: "hello" }];
Expand Down
8 changes: 7 additions & 1 deletion src/chat-promotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,16 @@ export function usePromotion(input: UsePromotionInput): UsePromotionResult {
const currentSessionIdRef = useRef(input.currentSessionId);
currentSessionIdRef.current = input.currentSessionId;

const clearCountRef = useRef(0);

const clearTranscript = useCallback(
(sessionId?: string) => {
clearScreen();
setPromotedRows((prev) => [...prev, createHeaderItem(input.version, sessionId ?? currentSessionIdRef.current)]);
clearCountRef.current += 1;
const id = sessionId ?? currentSessionIdRef.current;
const header = createHeaderItem(input.version, id);
header.id = `${header.id}_${clearCountRef.current}`;
setPromotedRows((prev) => [...prev, header]);
input.setRows(() => []);
},
[input.version, input.setRows],
Expand Down
7 changes: 0 additions & 7 deletions src/cli-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,6 @@ export function formatForTool(toolId: string, raw: string): string {
return (TOOL_FORMATTERS[toolId] ?? formatReadOutput)(raw);
}

export function formatReadDetail(pathInput: string, start?: string, end?: string): string {
if (!start && !end) return pathInput;
const from = start ?? "1";
const to = end ?? "EOF";
return `${pathInput}:${from}-${to}`;
}

export function formatRunSummary(
label: string,
tokenUsage: { usage: { inputTokens: number; outputTokens: number; totalTokens: number }; modelCalls?: number }[],
Expand Down
12 changes: 6 additions & 6 deletions src/cli-tool.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import { formatReadDetail, printToolResult } from "./cli-format";
import { printToolResult } from "./cli-format";
import { formatUsage } from "./cli-help";
import { editFile, findFiles, readSnippet, searchFiles } from "./file-ops";
import { editFile, findFiles, readFileContent, searchFiles } from "./file-ops";
import { gitDiff, gitStatusShort } from "./git-ops";
import { t } from "./i18n";
import { runShellCommand } from "./shell-ops";
Expand Down Expand Up @@ -74,14 +74,14 @@ function createToolHandlers(printError: (msg: string) => void): Record<string, T
printToolResult("web-fetch", result, url);
},
"read-file": async (rest) => {
const [pathInput, start, end] = rest;
const [pathInput] = rest;
if (!pathInput) {
printError(formatUsage("acolyte tool read-file <path> [start] [end]"));
printError(formatUsage("acolyte tool read-file <path>"));
process.exitCode = 1;
return;
}
const snippet = await readSnippet(process.cwd(), pathInput, start, end);
printToolResult("read-file", snippet, formatReadDetail(pathInput, start, end));
const content = await readFileContent(process.cwd(), pathInput);
printToolResult("read-file", content, pathInput);
},
"git-status": async () => {
const result = await gitStatusShort(process.cwd());
Expand Down
4 changes: 2 additions & 2 deletions src/code-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ export async function editCode(input: {
path: string;
edits: EditCodeEdit[];
}): Promise<EditCodeResult> {
const absPath = ensurePathWithinAllowedRoots(input.path, "AST edit", input.workspace);
const absPath = ensurePathWithinAllowedRoots(input.path, input.workspace);
const pathStats = await stat(absPath);

if (pathStats.isDirectory()) {
Expand Down Expand Up @@ -688,7 +688,7 @@ export async function scanCode(input: {
let scanned = 0;

const scanPath = async (rawPath: string) => {
const absPath = ensurePathWithinAllowedRoots(rawPath, "Scan", input.workspace);
const absPath = ensurePathWithinAllowedRoots(rawPath, input.workspace);
const info = await stat(absPath);

if (info.isFile()) {
Expand Down
46 changes: 40 additions & 6 deletions src/file-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { afterAll, describe, expect, test } from "bun:test";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { TOOL_ERROR_CODES } from "./error-contract";
import { deleteTextFile, editFile, findFiles, readSnippet, searchFiles, writeTextFile } from "./file-ops";
import {
deleteTextFile,
editFile,
findFiles,
readFileContent,
readFileContents,
searchFiles,
writeTextFile,
} from "./file-ops";
import { testUuid } from "./test-utils";

const WORKSPACE = resolve(process.cwd());
Expand All @@ -15,8 +23,8 @@ afterAll(async () => {
});

describe("path guards", () => {
test("readSnippet blocks paths outside workspace", async () => {
await expect(readSnippet(WORKSPACE, "/etc/hosts")).rejects.toThrow("restricted to the workspace or /tmp");
test("readFileContent blocks paths outside workspace", async () => {
await expect(readFileContent(WORKSPACE, "/etc/hosts")).rejects.toThrow("restricted to the workspace or /tmp");
});

test("editFile blocks paths outside workspace", async () => {
Expand All @@ -37,14 +45,40 @@ describe("path guards", () => {
);
});

test("readSnippet allows /tmp files", async () => {
test("readFileContent allows /tmp files", async () => {
const filePath = `/tmp/acolyte-test-read-${testUuid()}.txt`;
tempFiles.push(filePath);
await writeFile(filePath, "hello from tmp", "utf8");
const output = await readSnippet(WORKSPACE, filePath, "1", "1");
const output = await readFileContent(WORKSPACE, filePath);
expect(output).toContain("hello from tmp");
});

test("readFileContent rejects files exceeding maxLines", async () => {
const filePath = `/tmp/acolyte-test-large-${testUuid()}.txt`;
tempFiles.push(filePath);
const lines = Array.from({ length: 11 }, (_, i) => `line ${i + 1}`).join("\n");
await writeFile(filePath, lines, "utf8");
await expect(readFileContent(WORKSPACE, filePath, 10)).rejects.toThrow(/too large/);
});

test("readFileContent allows files at exactly maxLines", async () => {
const filePath = `/tmp/acolyte-test-exact-${testUuid()}.txt`;
tempFiles.push(filePath);
const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n");
await writeFile(filePath, lines, "utf8");
const output = await readFileContent(WORKSPACE, filePath, 10);
expect(output).toContain("line 1");
});

test("readFileContents rejects batch when any file exceeds maxLines", async () => {
const small = `/tmp/acolyte-test-small-${testUuid()}.txt`;
const large = `/tmp/acolyte-test-large-${testUuid()}.txt`;
tempFiles.push(small, large);
await writeFile(small, "ok", "utf8");
await writeFile(large, Array.from({ length: 11 }, (_, i) => `line ${i + 1}`).join("\n"), "utf8");
await expect(readFileContents(WORKSPACE, [small, large], 10)).rejects.toThrow(/too large/);
});

test("editFile allows /tmp files", async () => {
const filePath = `/tmp/acolyte-test-edit-${testUuid()}.txt`;
tempFiles.push(filePath);
Expand Down Expand Up @@ -348,7 +382,7 @@ describe("deleteTextFile", () => {
await writeFile(filePath, "alpha\nbeta\n", "utf8");
const result = await deleteTextFile({ workspace: WORKSPACE, path: filePath });
expect(result).toContain("bytes=");
await expect(readSnippet(WORKSPACE, filePath)).rejects.toThrow();
await expect(readFileContent(WORKSPACE, filePath)).rejects.toThrow();
});
});

Expand Down
32 changes: 14 additions & 18 deletions src/file-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
ensurePathWithinAllowedRoots,
isBinaryExtension,
resolveSearchScopeFiles,
toInt,
} from "./tool-utils";

export type FindReplaceEdit = { find: string; replace: string };
Expand Down Expand Up @@ -191,26 +190,23 @@ export async function searchFiles(
return "No matches.";
}

export async function readSnippet(workspace: string, pathInput: string, start?: string, end?: string): Promise<string> {
const absPath = ensurePathWithinAllowedRoots(pathInput, "Read", workspace);
export async function readFileContent(workspace: string, path: string, maxLines?: number): Promise<string> {
const absPath = ensurePathWithinAllowedRoots(path, workspace);
const raw = await readFile(absPath, "utf8");
const lines = raw.split("\n");

const from = toInt(start, 1);
const to = Math.max(from, toInt(end, Math.min(from + 119, lines.length)));
const slice = lines.slice(from - 1, to);
const numbered = slice.map((line, idx) => `${from + idx}: ${line}`);

if (maxLines !== undefined && lines.length > maxLines) {
throw new Error(
`File "${path}" is too large (${lines.length} lines). Use \`search-files\` or \`scan-code\` to find the relevant sections.`,
);
}
const numbered = lines.map((line, idx) => `${idx + 1}: ${line}`);
return [`File: ${absPath}`, ...numbered].join("\n");
}

export async function readSnippets(
workspace: string,
entries: Array<{ path: string; start?: string; end?: string }>,
): Promise<string> {
export async function readFileContents(workspace: string, paths: string[], maxLines?: number): Promise<string> {
const results: string[] = [];
for (const entry of entries) {
results.push(await readSnippet(workspace, entry.path, entry.start, entry.end));
for (const path of paths) {
results.push(await readFileContent(workspace, path, maxLines));
}
return results.join("\n\n");
}
Expand All @@ -221,7 +217,7 @@ export async function editFile(input: {
edits: FileEdit[];
dryRun?: boolean;
}): Promise<string> {
const absPath = ensurePathWithinAllowedRoots(input.path, "Edit", input.workspace);
const absPath = ensurePathWithinAllowedRoots(input.path, input.workspace);
const raw = await readFile(absPath, "utf8");
const lines = raw.split("\n");

Expand Down Expand Up @@ -372,7 +368,7 @@ export async function writeTextFile(input: {
content: string;
overwrite?: boolean;
}): Promise<string> {
const absPath = ensurePathWithinAllowedRoots(input.path, "Write", input.workspace);
const absPath = ensurePathWithinAllowedRoots(input.path, input.workspace);
const overwrite = input.overwrite ?? true;
let previousContent: string | null = null;

Expand All @@ -398,7 +394,7 @@ export async function writeTextFile(input: {
}

export async function deleteTextFile(input: { workspace: string; path: string; dryRun?: boolean }): Promise<string> {
const absPath = ensurePathWithinAllowedRoots(input.path, "Delete", input.workspace);
const absPath = ensurePathWithinAllowedRoots(input.path, input.workspace);
const previousContent = await readFile(absPath, "utf8");
const dryRun = input.dryRun ?? false;
if (!dryRun) await unlink(absPath);
Expand Down
Loading
Loading