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
4 changes: 4 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ CODEOID_COMPRESS_EXCLUDE= # comma-separated cmd prefixes to skip
CODEOID_COMPRESS_PIPES=0 # allow compressing piped commands
CODEOID_COMPRESS_MIN_BYTES=1024 # skip compression below this size

# Advisory guards (see FEATURES.md → Guards)
CODEOID_GUARD_REPEAT_TOOL=1 # loop-breaker advisory; on by default
CODEOID_GUARD_REPEAT_TOOL_EXCLUDE= # comma-separated tool patterns to ignore

# Auto-rotation (Layer D)
CODEOID_AUTO_ROTATE=0 # auto-rotate backing session near context ceiling
CODEOID_AUTO_ROTATE_WARN_PCT=0.75 # warn at this occupancy (no action)
Expand Down
42 changes: 42 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,48 @@ The session auto-approves up to 50 write/exec actions. Reads + greps + memory re

Status bar shows live budget: `autonomous (37 actions left)`. You can interrupt anytime with `Ctrl-X`.

### Guards

A *guard* observes the session and may inject model-facing advice.
It never vetoes a tool call, never rewrites arguments, and never appears in the tool list — anything that needs to *stop* a call belongs to the approval flow or the autonomous budget instead.

**Repeat-tool guard** (on by default) is the loop-breaker.
It counts runs of consecutive calls to the same tool with identical arguments, and at `3`, `5`, and `8` in a row it injects an escalating advisory telling the model to re-read the result it already has and either change approach or conclude.

This matters most where nobody is watching.
An unattended `dispatch` worker wedged on `Read(same file)` or `Bash(same failing command)` will otherwise burn its whole tool budget and report failure with no diagnosis; the budget caps the damage, the guard catches the cause while the turn can still recover.

Chains are tracked **per emitting agent**, so two subagents hammering the same tool in parallel are two independent runs rather than one interleaved chain that resets forever and never fires.
Argument comparison is order-insensitive (deep key-sort), so `{a:1, b:2}` and `{b:2, a:1}` are the same call.
Any inbound message — owner, background-task digest, or dispatch task — resets every chain, because the guard only claims "N identical calls with *nothing else happening*".

```jsonc
// ~/.codeoid/config.json
"guard": {
"repeatTool": {
"enabled": true,
"thresholds": [3, 5, 8], // run lengths that fire; each must be >= 2
"include": [], // patterns to track; empty = all tools
"exclude": ["TodoWrite", "todo_write"],
"argumentsPreviewChars": 500 // caps the reminder text, never detection
}
}
```

Invalid thresholds fail loud at construction rather than silently reverting to defaults — a guard that never fires because of a typo is worse than no guard. If the config is bad the session logs it and starts without the guard; an advisory plugin is never a reason to refuse a session.

#### Model Experience

The advisory arrives as injected context on the model's **next** request, wrapped in a `<repeat_tool_notice>` block and explicitly labelled as daemon-authored so it is never mistaken for owner input. The tool call that triggered it is unaffected — already recorded, already on its way to approval or execution.

Injection uses `later` priority, which merges into the running turn without starting a fresh query, so a reminder costs no extra turn. Backends without mid-turn injection get no reminders rather than a message arriving out of context.

#### KV Cache effect

Append-only. The advisory lands after the reusable request prefix and invalidates no prior cache entry. Cost is the advisory itself: ~60 tokens for the brief form, and up to `argumentsPreviewChars` more for the detailed form.

> The **Model Experience** / **KV Cache effect** headings are a convention borrowed from DeepSeek Harness (see [prior-art-deepseek-harness.md](./prior-art-deepseek-harness.md) §4.9). Any feature that changes what the model sees should state both: what reaches the model, and whether it invalidates the prompt prefix. For a harness, prefix stability *is* cost.

### Web UI

Mobile-first SolidJS SPA at `http://localhost:7400/ui/`. Also works as a Telegram Mini App:
Expand Down
339 changes: 339 additions & 0 deletions docs/prior-art-deepseek-harness.md

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,44 @@ const CompressSchema = z
minBytes: 1024,
});

/**
* Advisory guards (docs/prior-art-deepseek-harness.md §3.7). These observe the
* session and may inject model-facing advice; none of them can block a call.
* On by default — the guard is cheap, and the failure it catches (an unattended
* worker looping on one tool until its budget is gone) is expensive.
*/
const GuardSchema = z
.object({
repeatTool: z
.object({
enabled: z.boolean().default(true),
/** Consecutive-run lengths that trigger a reminder. Each must be >= 2. */
thresholds: z.array(z.number().int().min(2)).nonempty().default([3, 5, 8]),
/** Tool-name patterns to track (`*` wildcard). Empty ⇒ all tools. */
include: z.array(z.string()).default([]),
/** Tool-name patterns transparent to the chain. */
exclude: z.array(z.string()).default(["TodoWrite", "todo_write"]),
/** Cap on arguments quoted in the reminder — never on detection. */
argumentsPreviewChars: z.number().int().positive().default(500),
})
.default({
enabled: true,
thresholds: [3, 5, 8],
include: [],
exclude: ["TodoWrite", "todo_write"],
argumentsPreviewChars: 500,
}),
})
.default({
repeatTool: {
enabled: true,
thresholds: [3, 5, 8],
include: [],
exclude: ["TodoWrite", "todo_write"],
argumentsPreviewChars: 500,
},
});

const WorkspaceIndexSchema = z
.object({
enabled: z.boolean().default(true),
Expand Down Expand Up @@ -773,6 +811,7 @@ const RootSchema = z.object({
memory: MemorySchema,
workspaceIndex: WorkspaceIndexSchema,
compress: CompressSchema,
guard: GuardSchema,
labeling: LabelingSchema,
telemetry: TelemetrySchema,
autoRotate: AutoRotateSchema,
Expand Down Expand Up @@ -856,6 +895,20 @@ export interface CodeoidConfig {
compressPipes: boolean;
minBytes: number;
};
/**
* Advisory guards — observe and advise, never block. Optional on the type
* (like `hooks`) so a hand-built config literal need not carry it; the Zod
* schema still defaults it, so anything loaded through `loadConfig` has it.
*/
guard?: {
repeatTool: {
enabled: boolean;
thresholds: number[];
include: string[];
exclude: string[];
argumentsPreviewChars: number;
};
};
/** Cluster-label settings (Haiku API key). */
labeling: {
anthropicApiKey?: string;
Expand Down Expand Up @@ -1059,6 +1112,8 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [
{ env: "CODEOID_COMPRESS_EXCLUDE_PATTERNS", path: "compress.excludePatterns", kind: "csv" },
{ env: "CODEOID_COMPRESS_PIPES", path: "compress.compressPipes", kind: "boolean" },
{ env: "CODEOID_COMPRESS_MIN_BYTES", path: "compress.minBytes", kind: "int" },
{ env: "CODEOID_GUARD_REPEAT_TOOL", path: "guard.repeatTool.enabled", kind: "boolean" },
{ env: "CODEOID_GUARD_REPEAT_TOOL_EXCLUDE", path: "guard.repeatTool.exclude", kind: "csv" },
{ env: "ANTHROPIC_API_KEY", path: "labeling.anthropicApiKey", kind: "string" },
{ env: "CODEOID_OSC8", path: "telemetry.osc8", kind: "string" },
{ env: "CODEOID_AUTO_ROTATE", path: "autoRotate.enabled", kind: "boolean" },
Expand Down Expand Up @@ -1279,6 +1334,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig {
},
workspaceIndex: parsed.workspaceIndex,
compress: parsed.compress,
guard: parsed.guard,
labeling: parsed.labeling,
telemetry: { osc8: osc8Mode },
autoRotate: parsed.autoRotate,
Expand Down
21 changes: 21 additions & 0 deletions src/daemon/guard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Guard — advisory plugins that improve agent hygiene without taking authority.
*
* A guard observes the session's event stream and may inject model-facing
* advice. It never vetoes a tool call, never rewrites arguments, and never
* appears in the tool list. Anything that needs to *stop* a call belongs in the
* approval flow or the autonomous budget, not here.
*
* See docs/prior-art-deepseek-harness.md §3.7.
*/

export {
RepeatToolGuard,
DEFAULT_REPEAT_TOOL_CONFIG,
PRIMARY_CHAIN,
canonicalizeArguments,
matchesToolPattern,
normalizeRepeatToolConfig,
type RepeatToolGuardConfig,
type RepeatToolReminder,
} from "./repeat-tool.js";
Loading
Loading