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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
marker from them is an ordinary prompt, and `/agent-guard allow` from them
is refused. Background-task notifications and other sessions' messages
still pass untouched.
- **From 95%, wakeups and scheduled jobs are refused.** At the threshold
where new agents are refused, `ScheduleWakeup` and `CronCreate` are now
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.

## 1.1.3 — 2026-10-31

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ And it's not just me: [339 subagents from a single prompt](https://www.reddit.co
|---|---|---|---|
| Plan windows | New agents need approval once a window is this full | 80% | asks; approval lasts until that window resets |
| | New agents are refused once a window is this full | 95% | refuses |
| | `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 |
| Agents | Agents running at once, across every session on this machine | 4 | asks |
Expand Down Expand Up @@ -150,7 +151,7 @@ Set these as environment variables, or under `env` in `~/.claude/settings.json`.
| `AGENT_GUARD_AGENT_TOKENS_MAX` | Subagent tokens per window on this machine (`10000000`) |
| `AGENT_GUARD_DEPTH_MAX` | Agent layers below the main conversation (`1`) |
| `AGENT_GUARD_LIMIT_ASK_PERCENT` | Plan-window percentage from which new agents ask (`80`) |
| `AGENT_GUARD_LIMIT_DENY_PERCENT` | Plan-window percentage from which new agents are refused and each session is told to wind down (`95`) |
| `AGENT_GUARD_LIMIT_DENY_PERCENT` | Plan-window percentage from which new agents, wakeups and scheduled jobs are refused and each session is told to wind down (`95`) |
| `AGENT_GUARD_BURN_PERCENT` | 5-hour-window points per window that make new agents ask (`20`) |
| `AGENT_GUARD_CONTEXT_WARN` | Context at which Claude is told once to keep the turn bounded (`300000`) |
| `AGENT_GUARD_CONTEXT_HARD` | Context at which a prompt asks before it is sent (`500000`) |
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%, 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; 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
2 changes: 1 addition & 1 deletion core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,5 @@ export {
} from './state.ts'
export { USAGE, statusLine, statusLineExpiry, statusReport } from './status.ts'
export { canonical, contextBucket, fingerprint } from './text.ts'
export { type HeavyOutcome, type ToolRequest, resolveHeavyAsk, toolGate } from './tools.ts'
export { type HeavyOutcome, type ToolRequest, isScheduling, resolveHeavyAsk, scheduleGate, toolGate } from './tools.ts'
export { type Ask, type Deny, type Family, HEADER, type Rule, SIGNATURE, type Verdict } from './verdict.ts'
23 changes: 22 additions & 1 deletion core/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// loop carries a heavy context. Each loop is judged on its own context, so a
// subagent's calls never inherit the main conversation's size.

import { highestLimit, limitDetail } from './agents.ts'
import type { Config } from './config.ts'
import { type LoopId, MAIN, type SessionState, loopOf, overrideCovers, within } from './state.ts'
import { type LoopId, MAIN, type PeerRecord, type SessionState, loopOf, overrideCovers, within } from './state.ts'
import { count, duration, sentence, shortClock, tokens } from './text.ts'
import { ALLOW, type Ask, type Deny, type Verdict, refuse } from './verdict.ts'

Expand All @@ -18,6 +19,26 @@ const EXEMPT_TOOLS: ReadonlySet<string> = new Set([
'ExitPlanMode',
])

/** Tools that start a turn later, on their own: a wakeup, a scheduled job. */
const SCHEDULING: ReadonlySet<string> = new Set(['ScheduleWakeup', 'CronCreate'])

export function isScheduling(tool: string): boolean {
return SCHEDULING.has(tool)
}

/**
* From the refusal threshold, a wakeup or scheduled job is refused like a new
* agent: the turn it starts later would run into the limit with nobody there.
* The agents override lifts it.
*/
export function scheduleGate(state: SessionState, peers: readonly PeerRecord[], config: Config, now: number, request: ToolRequest): Verdict {
if (!config.enabled || !isScheduling(request.tool) || overrideCovers(state, now, 'agents')) return ALLOW
const limit = highestLimit(state, peers, now)
if (!limit || limit.percent < config.limitDenyPercent) return ALLOW
const base = `This ${request.tool} call was refused: ${limitDetail(limit)}, and the turn it starts would run into the limit. Finish the step in progress instead; the user can lift this with /agent-guard allow.`
return refuse(state, config, now, 'schedule', `schedule:${request.tool}`, base, false)
}

export type ToolRequest = {
loop: LoopId
tool: string
Expand Down
2 changes: 2 additions & 0 deletions core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type Rule =
| 'depth'
| 'fuse'
| 'limit-deny'
// A wakeup or scheduled job, past the refusal threshold.
| 'schedule'
// Agents, asked when someone can answer.
| 'concurrency'
| 'starts'
Expand Down
6 changes: 5 additions & 1 deletion hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
helpText,
highestLimit,
isJournalRow,
isScheduling,
journalKey,
livePeers,
lockoutKind,
Expand Down Expand Up @@ -59,6 +60,7 @@ import {
resolveHeavyAsk,
resolvePromptAsk,
resumeGuard,
scheduleGate,
splitJournalKeys,
statusLine,
statusLineExpiry,
Expand Down Expand Up @@ -379,7 +381,9 @@ export function register(on: On): void {
if (!resumed) {
const label = loop === MAIN ? 'this conversation' : agentLabel(loop)
const request = { loop, tool: e.tool, fingerprint: print, label }
let verdict = toolGate(session, config, now, request)
if (isScheduling(e.tool)) await loadPeers($, now)
let verdict = scheduleGate(session, peers, config, now, request)
if (verdict.kind === 'allow') verdict = toolGate(session, config, now, request)
if (verdict.kind === 'ask') verdict = await settleHeavy($, verdict, request)
if (verdict.kind === 'deny') {
await journalDenial($, verdict, e.tool, contextBucket(session.loops[loop]?.context))
Expand Down
31 changes: 31 additions & 0 deletions tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,34 @@ describe('a Stop hook that reopens the turn', () => {
expect(String(second.deny)).toMatch(/A Stop hook reopened this turn, so ending the turn will not clear this\. Tell the user/)
})
})

describe('scheduled work near the limit', () => {
const measure = (percent: number) => ({
context: { window: 1_000_000 },
rateLimits: [{ kind: 'five_hour', percentUsed: percent, resetsAt: '2027-01-15T15:00:00.000Z' }],
changed: ['rateLimits'],
})

test('from 95% a wakeup or a scheduled job is refused, and other tools still run', async ($, on) => {
const w = world(on)
await start($, w, true)
await $.session.measure(measure(96) as never)
const wakeup = await $.tool.call({ tool: 'ScheduleWakeup', delaySeconds: 1200, prompt: 'check CI' } as never)
expect(String((wakeup as Record<string, unknown>).deny)).toMatch(/^This ScheduleWakeup call was refused: the 5-hour window is 96% used \(resets \d\d:\d\d\), and the turn it starts would run into the limit\./)
const cron = await $.tool.call({ tool: 'CronCreate', cron: '*/5 * * * *', prompt: 'check CI' } as never)
expect(String((cron as Record<string, unknown>).deny)).toMatch(/^This CronCreate call was refused/)
expect(await $.tool.call({ tool: 'Read', file_path: '/a' } as never)).toEqual({ result: 'ok' })
expect(w.ran).toEqual(['Read'])
expect(journalRows(w).filter((r) => r.ev === 'deny').map((r) => r.rule)).toEqual(['schedule', 'schedule'])
})

test('below 95% it runs, and /agent-guard allow lifts the hold', async ($, on) => {
const w = world(on)
await start($, w, true)
await $.session.measure(measure(90) as never)
expect(await $.tool.call({ tool: 'ScheduleWakeup', delaySeconds: 1200, prompt: 'x' } as never)).toEqual({ result: 'ok' })
await $.session.measure(measure(97) as never)
await $.command.run({ command: 'agent-guard', args: 'allow agents', origin: { kind: 'composer' } } as never)
expect(await $.tool.call({ tool: 'CronCreate', cron: '0 9 * * *', prompt: 'x' } as never)).toEqual({ result: 'ok' })
})
})
Loading