Skip to content

Commit 36bd00a

Browse files
committed
add --max-anchors-per-batch and shard an anchor-dense file across prompts
1 parent 263576d commit 36bd00a

6 files changed

Lines changed: 808 additions & 29 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ preamble ─▶ recon ─▶ preconditions ─▶ AGENTS ─▶ validate ─▶
134134
- *Prompt gates*: agents that declare an LLM `prompt` (and passed regex) are evaluated **concurrently** in a pool at `--concurrency`. Barrier: the entire queued set is decided, and `plan.json` written, before any agent runs. A cached plan covering your `-t` selection is reused instead of re-running the gates.
135135

136136
**3. Agents.** The core, split into a cheap sequential setup and a parallel drain.
137-
- *3a. Enqueue* (sequential, no LLM). For each queued agent, in order: resolve `where` → walk + `preFilter` to candidate files, intersect with the `--diff` set, apply the `--max-files-per-agent` cap (default 300), drop already-analyzed files via per-file resume, then split the remainder into **batches of `maxFilesPerBatch` (default 5)**. Every `(agent, batch)` pair is pushed onto **one shared queue**. An agent whose prior run still matches scope is lifted from disk and skipped entirely here. `--max-batches` (default 250) then truncates the queue.
137+
- *3a. Enqueue* (sequential, no LLM). For each queued agent, in order: resolve `where` → walk + `preFilter` to candidate files, intersect with the `--diff` set, apply the `--max-files-per-agent` cap (default 300), drop already-analyzed files via per-file resume, then split any file carrying more than `--max-anchors-per-batch` anchors (default 150) into per-shard candidates, and pack the remainder into batches under two ceilings: **`maxFilesPerBatch` files (default 5)** and that same anchor count. Every `(agent, batch)` pair is pushed onto **one shared queue**. An agent whose prior run still matches scope is lifted from disk and skipped entirely here. `--max-batches` (default 250) then truncates the queue.
138138
- *3b. Drain* (concurrent). One bounded pool runs over **every `(agent, batch)` pair across all agents** at `--concurrency`. This is the key design point: batches from *different* agents interleave in the same pool, so agents are **not** run one-at-a-time. Each batch is one tool-enabled LLM session (up to the agent's `maxTurnsPerBatch`, default 50) that reviews its files and follows imports/callers outward. A fast agent's batches and a slow agent's batches overlap instead of blocking each other.
139139

140140
```
@@ -483,6 +483,7 @@ agentgg scan ./src --only "src/api/**/*.ts" --only "src/handlers/**/*.ts" -o ./o
483483
agentgg scan ./src --max-file-size 200 -o ./out # skip files larger than 200 KB (default 500)
484484
agentgg scan ./src --max-files-per-agent 30 -o ./out # each agent reviews at most 30 files (default 300)
485485
agentgg scan ./src --max-batches 50 -o ./out # run at most 50 agent batches this scan (default 250)
486+
agentgg scan ./src --max-anchors-per-batch 40 -o ./out # at most 40 scanner anchors per prompt (default 150)
486487
agentgg scan ./src --no-max-files-per-agent --no-max-batches --no-max-file-size -o ./out # disable every cap (fully uncapped)
487488
```
488489

@@ -494,6 +495,8 @@ Auto-exclude (**on by default**; disable with `--no-auto-exclude`) lets the mode
494495

495496
`--max-batches` is a whole-scan ceiling on the number of agent batches (**default 250**). Once every `(agent, batch)` pair is enqueued, the pool is truncated to `<n>` (in enqueue order) and the rest are dropped before any LLM call runs. Agents whose batches are dropped write no completion sidecar, so a later scan picks them up where this one stopped — a way to spread a large scan across several runs or cap the spend of a single run. Pass `--no-max-batches` to disable the cap. Different from `--concurrency`, which bounds how many batches run in parallel, not how many run at all.
496497

498+
`--max-anchors-per-batch` is a per-batch ceiling on scanner anchor lines (**default 150**) — the sibling of `--max-files-per-batch`: same batch, different unit. Both are ceilings, and whichever binds first closes the batch. When a single file carries more anchors than the cap (one rule matching 500 routes in one router), it is split into shards of at most `<n>`, and each prompt anchors on a contiguous line range, so no prompt gets hundreds of anchors at once. Each shard still receives the whole file as context, so an N-shard file sends its content N times. Anchors on the same line count once, and regex and semgrep anchors count the same. Shards resume independently: an interrupted scan re-runs only the shards that did not finish. Pass `--no-max-anchors-per-batch` to disable the cap. A low value produces many more batches, so raise `--max-batches` alongside it.
499+
497500
`--max-file-size` (**default 500 KB**) skips files larger than the limit before any agent sees them. Pass `--no-max-file-size` to scan files of any size.
498501

499502
### Use a one-off credential or model without saving it
@@ -647,6 +650,8 @@ Run `agentgg <command> --help` for the full flag list on any subcommand.
647650
--no-recon skip the recon survey AND precondition gating; run every -t agent unconditionally
648651
--no-summary skip writing the markdown report (summary.md + findings/*.md); state still persists
649652
--max-files-per-batch <n> candidate files per agent batch (overrides the agent's where.maxFilesPerBatch)
653+
--max-anchors-per-batch <n> cap the scanner anchors packed into one batch; a file with more is split into shards, one prompt each (default 150, --no-max-anchors-per-batch disables)
654+
--no-max-anchors-per-batch disable the per-batch anchor cap (never split a file)
650655
--max-files-per-agent <n> cap the candidate files each agent reviews — keep the first <n> in scan order, drop the rest (guardrail against an over-broad agent; default 300, --no-max-files-per-agent disables)
651656
--no-max-files-per-agent disable the per-agent candidate-file cap (review every file)
652657
--max-batches <n> cap the total agent batches run this scan — keep the first <n> in enqueue order, drop the rest (dropped agents re-run next scan; default 250, --no-max-batches disables)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
anchorLoad,
4+
packBatches,
5+
shardCandidate,
6+
shardKeyOf,
7+
toLocations,
8+
} from "../src/anchors.js";
9+
import type { AgentCandidate } from "../src/detect.js";
10+
import type { PreFilterHit } from "../src/pre-filter.js";
11+
12+
function hit(line: number, label = "r", endLine?: number): PreFilterHit {
13+
return { line, label, snippet: `line ${line}`, ...(endLine !== undefined ? { endLine } : {}) };
14+
}
15+
16+
function candidate(filePath: string, hits: PreFilterHit[]): AgentCandidate {
17+
return { filePath, content: "x\n".repeat(600), hits };
18+
}
19+
20+
describe("toLocations", () => {
21+
it("collapses hits that point at the same line", () => {
22+
const locs = toLocations([hit(7, "regex-a"), hit(7, "semgrep-b"), hit(9)]);
23+
expect(locs.length).toBe(2);
24+
expect(locs[0].hits.length).toBe(2);
25+
});
26+
27+
it("keeps a ranged anchor separate from a single-line one", () => {
28+
const locs = toLocations([hit(7), hit(7, "r", 12)]);
29+
expect(locs.length).toBe(2);
30+
});
31+
32+
it("sorts by line regardless of build order", () => {
33+
// Real hits arrive pattern-major, then semgrep appended — never sorted.
34+
const locs = toLocations([hit(40), hit(2), hit(17)]);
35+
expect(locs.map((l) => l.line)).toEqual([2, 17, 40]);
36+
});
37+
});
38+
39+
describe("anchorLoad", () => {
40+
it("charges once for several patterns matching one line", () => {
41+
expect(anchorLoad(candidate("a.ts", [hit(3, "a"), hit(3, "b"), hit(3, "c")]))).toBe(1);
42+
});
43+
});
44+
45+
describe("shardCandidate", () => {
46+
const many = Array.from({ length: 500 }, (_, i) => hit(i + 1));
47+
48+
it("returns the same object untouched when the file is under the cap", () => {
49+
const c = candidate("a.ts", [hit(5), hit(1)]);
50+
const shards = shardCandidate(c, 150);
51+
expect(shards.length).toBe(1);
52+
// Identity matters: an unsharded scan must render byte-identical prompts,
53+
// which means the original hit order survives.
54+
expect(shards[0]).toBe(c);
55+
});
56+
57+
it("splits an over-cap file into contiguous line ranges", () => {
58+
const shards = shardCandidate(candidate("a.ts", many), 50);
59+
expect(shards.length).toBe(10);
60+
expect(shards[0].hits[0].line).toBe(1);
61+
expect(shards[0].hits.at(-1)?.line).toBe(50);
62+
expect(shards[1].hits[0].line).toBe(51);
63+
expect(shards.at(-1)?.hits.at(-1)?.line).toBe(500);
64+
});
65+
66+
it("covers every anchor exactly once", () => {
67+
const shards = shardCandidate(candidate("a.ts", many), 33);
68+
const lines = shards.flatMap((s) => s.hits.map((h) => h.line));
69+
expect(lines.length).toBe(500);
70+
expect(new Set(lines).size).toBe(500);
71+
});
72+
73+
it("never splits when the cap is disabled", () => {
74+
expect(shardCandidate(candidate("a.ts", many), Number.POSITIVE_INFINITY).length).toBe(1);
75+
});
76+
});
77+
78+
describe("shardKeyOf", () => {
79+
it("gives disjoint shards of one file distinct keys", () => {
80+
const shards = shardCandidate(
81+
candidate(
82+
"a.ts",
83+
Array.from({ length: 120 }, (_, i) => hit(i + 1)),
84+
),
85+
50,
86+
);
87+
const keys = shards.map(shardKeyOf);
88+
expect(new Set(keys).size).toBe(keys.length);
89+
expect(keys[0]).toBe("1-50:50");
90+
});
91+
92+
it("is stable across rebuilds of the same candidate", () => {
93+
const hits = [hit(9), hit(2), hit(2, "other")];
94+
expect(shardKeyOf(candidate("a.ts", hits))).toBe(shardKeyOf(candidate("a.ts", [...hits])));
95+
});
96+
97+
it("changes when the anchor set shifts, so a stale cut cannot resume", () => {
98+
const before = shardKeyOf(candidate("a.ts", [hit(1), hit(2)]));
99+
const after = shardKeyOf(candidate("a.ts", [hit(1), hit(2), hit(3)]));
100+
expect(before).not.toBe(after);
101+
});
102+
});
103+
104+
describe("packBatches", () => {
105+
const light = (name: string) => candidate(name, [hit(1), hit(2)]);
106+
107+
it("matches the fixed slice it replaces when the anchor cap is off", () => {
108+
const items = ["a", "b", "c", "d", "e", "f", "g"].map(light);
109+
const batches = packBatches(items, 5, Number.POSITIVE_INFINITY);
110+
expect(batches.map((b) => b.length)).toEqual([5, 2]);
111+
});
112+
113+
it("closes a batch early when the anchor ceiling binds first", () => {
114+
const dense = (name: string) =>
115+
candidate(
116+
name,
117+
Array.from({ length: 40 }, (_, i) => hit(i + 1)),
118+
);
119+
const batches = packBatches([dense("a"), dense("b"), dense("c")], 5, 50);
120+
expect(batches.map((b) => b.length)).toEqual([1, 1, 1]);
121+
});
122+
123+
it("keeps two shards of one file out of the same batch", () => {
124+
const shards = shardCandidate(
125+
candidate(
126+
"big.ts",
127+
Array.from({ length: 300 }, (_, i) => hit(i + 1)),
128+
),
129+
100,
130+
);
131+
const batches = packBatches(shards, 5, 100);
132+
expect(batches.length).toBe(3);
133+
for (const b of batches) expect(b.length).toBe(1);
134+
});
135+
136+
it("still emits an oversized candidate rather than dropping it", () => {
137+
const huge = candidate(
138+
"huge.ts",
139+
Array.from({ length: 80 }, (_, i) => hit(i + 1)),
140+
);
141+
const batches = packBatches([huge], 5, 10);
142+
expect(batches.length).toBe(1);
143+
expect(batches[0][0]).toBe(huge);
144+
});
145+
});

0 commit comments

Comments
 (0)