Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
refused too, with the plan window and its reset in the reason: the turn
each would start later runs into the limit with nobody there. Other tools
run as before, and `/agent-guard allow` (or `allow agents`) lifts it.
- **The burn rule looks at time to empty.** New agents asked only when the
5-hour window rose 20 points in 10 minutes. Now they also ask when the
window's pace over those 10 minutes would fill it before it resets: 30% to
38% in five minutes, three hours before the reset, fills it in about 40
minutes. The question says when: "At this pace the 5-hour window fills by
12:43, before it resets at 15:00 (now 38%)". A rise of under two points, or
over less than two minutes, projects nothing, since readings move in steps.

## 1.1.3 — 2026-10-31

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ And it's not just me: [339 subagents from a single prompt](https://www.reddit.co
| | `ScheduleWakeup` and `CronCreate` are refused once a window is this full, so no turn starts itself into the limit | 95% | refuses |
| | Each session is told once to finish its step and say what is left | 95% | adds a note to the next prompt |
| | New agents need approval when the 5-hour window rises this fast | 20 points / 10 min | asks |
| | New agents need approval when the 5-hour window's pace over the last 10 minutes would fill it before it resets | at least 2 points over 2 min | asks |
| Agents | Agents running at once, across every session on this machine | 4 | asks |
| | Agents started or resumed per 10 minutes, across this machine | 12 | asks |
| | Tokens processed by subagents per 10 minutes, across this machine | 10M | asks |
Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ npx -p typescript@5.9 tsc -p tsconfig.json
| Surface | Evidence |
|---|---|
| Agent limits | Concurrency, rolling starts and subagent tokens; a finished agent frees its slot, one in a long tool call keeps it, and one that went quiet counts again at its next request; peers' counts add up, their tokens age out with the window, and stale or malformed records expire or are ignored; a parallel batch of five lets exactly four through; nesting refused; resumes by `SendMessage` gated and counted once, also when Claude Code raises `agent.spawn` for them; a resume through `agent.spawn` alone gated as a resume; `/subtask` forks counted, even while a gated agent is starting, internal agents not |
| Plan windows | Refused from 95%, wakeups and scheduled jobs too, while other tools run and an allow lifts it; asked once from 80% with the approval lasting to reset; weekly window; expired windows ignored; burn rate; threshold crossings journaled once |
| Plan windows | Refused from 95%, wakeups and scheduled jobs too, while other tools run and an allow lifts it; asked once from 80% with the approval lasting to reset; weekly window; expired windows ignored; burn rate, and a pace that would fill the window before it resets, but not a slower one or a one-point blip; threshold crossings journaled once |
| Questions | Approval leases the condition; declining refuses without asking again; typed answers reach the model; a question that ends without an answer, however it ends, counts as dismissed and never as a yes; an agent the guard failed to judge is refused; calls made while a question is open are held back without waiting, and run once it is answered |
| Tool gate | Retry fuse on identical failures in a row only, so test, edit, re-run is not a loop; a permission refusal is not a failure; the heavy-context budget asks once, stops the turn, or compacts after it; subagents judged on their own context, and named in a question only by a plain name; `SubagentHandback` and `TaskStop` never held; compaction, or a context reading below the threshold, clears the budget |
| Prompt gate | Heavy prompt asks, compacts first, or cancels, and the prompt waits in the input box either way, never sent by the guard; a compaction that fails or a hook vetoes says so; headless, scheduled and relayed prompts hold with a reason, and never lift limits; notifications pass untouched; dormant resumes ask or are capped across sessions; the context warning is given once, on a prompt the user typed; from 95% of a plan window, read in any session, each session is told once to wind down, and again only in a new window; a session compacted while idle is not asked |
Expand Down
30 changes: 27 additions & 3 deletions core/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ function agentTrips(
leaseKey: 'burn',
leaseUntil: until,
})
} else if (burned?.fullAt !== undefined && burned.resetsAt !== undefined) {
trips.push({
rule: 'burn',
detail: `at this pace the 5-hour window fills by ${shortClock(burned.fullAt)}, before it resets at ${shortClock(burned.resetsAt)} (now ${burned.percent}%)`,
leaseKey: 'burn',
leaseUntil: until,
})
}

if (limit && limit.percent >= config.limitAskPercent) {
Expand Down Expand Up @@ -206,13 +213,21 @@ export function limitDetail(limit: LimitReading): string {
return `the ${windowName(limit.kind)} window is ${limit.percent}% used${reset}`
}

/** Points the 5-hour window rose within the rolling window, across this machine. */
/** A rise shorter or smaller than this is too coarse to project a pace from: readings move in steps. */
const PACE_MIN_MS = 2 * 60_000
const PACE_MIN_POINTS = 2

/**
* Points the 5-hour window rose within the rolling window, across this
* machine, and, at the pace of that rise, when it fills if that comes before
* it resets.
*/
function burn(
state: SessionState,
peers: readonly PeerRecord[],
now: number,
windowMs: number,
): { points: number; percent: number } | undefined {
): { points: number; percent: number; resetsAt?: number; fullAt?: number } | undefined {
const own = state.limits.five_hour
const peerReadings = peers.flatMap((p) => (p.limit?.kind === 'five_hour' ? [p.limit] : []))
const current = [own, ...peerReadings].reduce<LimitReading | undefined>(
Expand All @@ -225,7 +240,16 @@ function burn(
)
if (sameWindow.length === 0) return undefined
const lowest = Math.min(...sameWindow.map((r) => r.percent))
return { points: Math.round((current.percent - lowest) * 10) / 10, percent: current.percent }
const points = Math.round((current.percent - lowest) * 10) / 10
const first = sameWindow.reduce((a, b) => (b.at < a.at ? b : a))
const span = current.at - first.at
const rise = current.percent - first.percent
let fullAt: number | undefined
if (span >= PACE_MIN_MS && rise >= PACE_MIN_POINTS && current.resetsAt !== undefined) {
const at = current.at + ((100 - current.percent) * span) / rise
if (at < current.resetsAt) fullAt = at
}
return { points, percent: current.percent, ...(current.resetsAt === undefined ? {} : { resetsAt: current.resetsAt }), ...(fullAt === undefined ? {} : { fullAt }) }
}

function notRun(resume: boolean): string {
Expand Down
25 changes: 25 additions & 0 deletions tests/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,31 @@ describe('plan-window gate', () => {
expect(w.questions[0]).toMatch(/the 5-hour window rose 22 points in the last 10 min \(now 52%\)/i)
})

test('a pace that would fill the 5-hour window before it resets makes new agents ask, below the burn threshold', async ($, on) => {
const w = world(on, { answers: ["Don't start it"] })
await start($, w, true)
await $.session.measure(measure(30) as never)
await w.clock.advance(5 * MINUTE)
await $.session.measure(measure(38) as never)
await spawn($, w)
expect(w.questions[0]).toMatch(/^At this pace the 5-hour window fills by \d\d:\d\d, before it resets at \d\d:\d\d \(now 38%\)\./)
})

test('a pace that leaves the window short of full at its reset asks nothing, and neither does a one-point blip', async ($, on) => {
const w = world(on)
await start($, w, true)
await $.session.measure(measure(30) as never)
await w.clock.advance(9 * MINUTE)
await $.session.measure(measure(33) as never)
expect((await spawn($, w)).agentId).toBeDefined()
await w.clock.advance(10 * MINUTE)
await $.session.measure(measure(60) as never)
await w.clock.advance(MINUTE)
await $.session.measure(measure(61) as never)
expect((await spawn($, w)).agentId).toBeDefined()
expect(w.questions).toEqual([])
})

test('crossing a threshold is journaled once', async ($, on) => {
const w = world(on)
await start($, w, false)
Expand Down
Loading