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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
judge for any reason is now refused, so Claude starts it again; before,
only an overrun was. Tool calls and prompts still go ahead when the guard
fails.
- **Haiku agents stay out of the running and starts limits.** A burst of
Haiku agents, such as Explore searches, asked like any fan-out, though
Haiku draws far less on a plan's usage than larger models. An agent Claude starts on Haiku
now passes the running and starts limits and doesn't count against them;
one whose own definition picks Haiku stops counting once it starts. Haiku
agents still count toward the subagent token budget and every plan-window
rule. `AGENT_GUARD_HAIKU_EXEMPT=0` counts them like any other.

## 1.1.1 — 2026-10-23

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ And it's not just me: [339 subagents from a single prompt](https://www.reddit.co
| Loops | The same tool call failing again and again, with no other call in between | 3 identical failures | refuses the identical retry |
| | Refused agent calls within 10 minutes | 5 | pauses agent spawns for 10 min |

Agents covers subagents, agent-team teammates, workflow agents, and finished subagents resumed with `SendMessage`. Each tool call is judged on the context of the loop that makes it, so a small subagent never inherits the main conversation's size.
Agents covers subagents, agent-team teammates, workflow agents, and finished subagents resumed with `SendMessage`. Haiku agents stay out of the running and starts limits, whether Claude asks for Haiku or the agent's own definition picks it; they still count toward the subagent token budget and every plan-window rule. Each tool call is judged on the context of the loop that makes it, so a small subagent never inherits the main conversation's size.

**When it asks.** The question appears in Claude Code's own dialog, the one Claude uses to ask you something, and the call waits for your answer. Agents started in parallel while it is open are held back, and Claude starts them again once you answer. If you allow it, that condition stays allowed for a while: a plan window until it resets, a heavy context until it shrinks (for tool calls, an hour at most), the agent counts for 10 minutes. If you decline or dismiss the question, or it closes without an answer for any other reason, Claude is told not to start agents for the next 10 minutes (`AGENT_GUARD_WINDOW_SECONDS`), and is not asked again in that time. Typed words reach Claude, so you can answer "use haiku agents instead".

Expand Down Expand Up @@ -141,6 +141,7 @@ Set these as environment variables, or under `env` in `~/.claude/settings.json`.
|---|---|
| `AGENT_GUARD` | `0`, `false`, `off` or `no` turns the guard off (`1`) |
| `AGENT_GUARD_JOURNAL` | `0` stops the journal (`1`) |
| `AGENT_GUARD_HAIKU_EXEMPT` | `0` counts Haiku agents in the running and starts limits like any other (`1`: they stay out) |
| `AGENT_GUARD_JOURNAL_DAYS` | Days of journal kept (`90`) |
| `AGENT_GUARD_WINDOW_SECONDS` | Rolling window for budgets, burn and refusals (`600`) |
| `AGENT_GUARD_AGENT_MAX` | Agents running at once on this machine (`4`) |
Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,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 records expire; 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 |
| Agent limits | Concurrency, rolling starts and subagent tokens; Haiku agents left out of the first two, whether asked for or picked by their definition, unless switched off; 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 records expire; 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 |
| 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; `SubagentHandback` and `TaskStop` never held; compaction, or a context reading below the threshold, clears the budget |
Expand Down
14 changes: 11 additions & 3 deletions core/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export type AgentRequest = {
depth: number
/** True when a finished agent is being sent a new task. */
resume: boolean
/** It runs on Haiku: the running and starts caps leave it out. */
cheap?: boolean
}

/** A model id or alias for Haiku, the tier the running and starts caps leave out. */
export function isHaiku(model: string | undefined): boolean {
return model !== undefined && /haiku/i.test(model)
}

const AGENT_ALLOW = 'Allow'
Expand Down Expand Up @@ -43,7 +50,7 @@ export function agentGate(
return refuse(state, config, now, 'limit-deny', 'limit-deny', base, true)
}

const trips = agentTrips(state, peers, config, now, limit).filter((t) => !leased(state, t, now))
const trips = agentTrips(state, peers, config, now, limit, request.cheap === true).filter((t) => !leased(state, t, now))
if (trips.length === 0) return ALLOW

const details = join(trips.map((t) => t.detail))
Expand Down Expand Up @@ -112,14 +119,15 @@ function agentTrips(
config: Config,
now: number,
limit: LimitReading | undefined,
cheap: boolean,
): Trip[] {
const window = config.windowMs
const span = duration(window)
const until = now + window
const trips: Trip[] = []

const running = runningAgents(state) + peers.reduce((sum, p) => sum + p.running, 0)
if (running >= config.agentMax) {
if (!cheap && running >= config.agentMax) {
trips.push({
rule: 'concurrency',
detail: `${count(running, 'agent')} ${running === 1 ? 'is' : 'are'} running on this machine (limit ${config.agentMax})`,
Expand All @@ -129,7 +137,7 @@ function agentTrips(
}

const starts = countOnMachine(state, peers, 'starts', now, window)
if (starts >= config.rollingMax) {
if (!cheap && starts >= config.rollingMax) {
trips.push({
rule: 'starts',
detail: `${count(starts, 'agent')} started on this machine in the last ${span} (limit ${config.rollingMax})`,
Expand Down
8 changes: 5 additions & 3 deletions core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ export type Config = {
peerTtlMs: number
journal: boolean
journalDays: number
/** Haiku agents stay out of the running and starts counts. */
haikuExempt: boolean
}

type Field = Exclude<keyof Config, 'enabled' | 'journal'>
type Field = Exclude<keyof Config, 'enabled' | 'journal' | 'haikuExempt'>

type Spec = {
field: Field
Expand Down Expand Up @@ -82,12 +84,12 @@ export const SPECS: readonly Spec[] = [
{ field: 'journalDays', env: 'AGENT_GUARD_JOURNAL_DAYS', default: 90, min: 1, max: 3650, scale: 1 },
]

export const SWITCHES = { enabled: 'AGENT_GUARD', journal: 'AGENT_GUARD_JOURNAL' } as const
export const SWITCHES = { enabled: 'AGENT_GUARD', journal: 'AGENT_GUARD_JOURNAL', haikuExempt: 'AGENT_GUARD_HAIKU_EXEMPT' } as const

const OFF = new Set(['0', 'false', 'off', 'no'])

export function defaultConfig(): Config {
const config = { enabled: true, journal: true } as Config
const config = { enabled: true, journal: true, haikuExempt: true } as Config
for (const spec of SPECS) config[spec.field] = spec.default * spec.scale
return config
}
Expand Down
2 changes: 1 addition & 1 deletion core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// no clock: an adapter reports facts, asks for verdicts, and carries them out.
// See docs/architecture.md for the contract an adapter keeps.

export { type AgentRequest, agentGate, heldForQuestion, highestLimit, resolveAgentAsk } from './agents.ts'
export { type AgentRequest, agentGate, heldForQuestion, highestLimit, isHaiku, resolveAgentAsk } from './agents.ts'
export { type Command, DEFAULT_ALLOW_MINUTES, applyOverride, helpText, parseCommand, resumeGuard } from './commands.ts'
export { type Config, SPECS, SWITCHES, defaultConfig, parseConfig } from './config.ts'
export { type JournalEvent, type JournalRow, formatReport, isJournalRow, journalKey, splitJournalKeys } from './journal.ts'
Expand Down
14 changes: 8 additions & 6 deletions core/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export type SpawnFacts = {
kind: AgentKind
depth: number
name?: string
cheap?: boolean
}

/**
Expand All @@ -106,16 +107,16 @@ export function reserveStart(state: SessionState, now: number): number {

/**
* Ends a reservation: `agentId` started, or, when it is undefined, the start
* failed and is not counted. Once no gated agent is starting, the starts none
* of them claimed are counted.
* failed and is not counted. A `cheap` agent started on Haiku and is not
* counted either. Once no gated agent is starting, the starts none of them
* claimed are counted.
*/
export function releaseStart(state: SessionState, reservedAt: number, agentId: string | undefined): void {
export function releaseStart(state: SessionState, reservedAt: number, agentId: string | undefined, cheap = false): void {
state.pendingStarts = Math.max(0, state.pendingStarts - 1)
if (agentId === undefined) {
if (agentId !== undefined) delete state.unclaimed[agentId]
if (agentId === undefined || cheap) {
const index = state.starts.indexOf(reservedAt)
if (index >= 0) state.starts.splice(index, 1)
} else {
delete state.unclaimed[agentId]
}
countUnclaimed(state)
}
Expand All @@ -129,6 +130,7 @@ export function observeSpawn(state: SessionState, now: number, facts: SpawnFacts
running: true,
startedAt: now,
...(facts.name ? { name: facts.name } : {}),
...(facts.cheap ? { cheap: true } : {}),
}
}

Expand Down
4 changes: 3 additions & 1 deletion core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type AgentRecord = {
running: boolean
startedAt: number
name?: string
/** It runs on Haiku, so the running and starts counts leave it out. */
cheap?: boolean
}

export type LoopState = {
Expand Down Expand Up @@ -152,7 +154,7 @@ export function countOnMachine(

/** Running agents, counting those allowed a moment ago that are still starting. */
export function runningAgents(state: SessionState): number {
return Object.values(state.agents).filter((a) => a.running).length + state.pendingStarts
return Object.values(state.agents).filter((a) => a.running && !a.cheap).length + state.pendingStarts
}

export function overrideCovers(state: SessionState, now: number, scope: Exclude<Scope, 'all'>): boolean {
Expand Down
18 changes: 13 additions & 5 deletions hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
heldForQuestion,
helpText,
highestLimit,
isHaiku,
isJournalRow,
journalKey,
livePeers,
Expand Down Expand Up @@ -342,24 +343,29 @@ export function register(on: On): void {
if (resumed !== undefined && gatedResumes.has(resumed)) return next(e)
const known = resumed === undefined ? undefined : session.agents[resumed]
const parentDepth = e.parentAgentId ? (session.agents[e.parentAgentId]?.depth ?? 1) : 0
const request = { loop, depth: known?.depth ?? parentDepth + 1, resume: resumed !== undefined }
// A fork runs on its parent's model; any other agent on the one it names, else its own definition's or its parent's.
const asked = config.haikuExempt && isHaiku(e.fork ? e.parentModel : e.model)
const request = { loop, depth: known?.depth ?? parentDepth + 1, resume: resumed !== undefined, cheap: known?.cheap ?? asked }
const { verdict, reservedAt } = await agentVerdict($, now, request)
if (verdict.kind === 'deny') {
await journalDenial($, verdict, 'Agent')
return { deny: verdict.message }
}
let started: string | undefined
let cheap = request.cheap
try {
const result = await next(e)
if (!result.deny && result.agentId) {
started = result.agentId
const kind = e.isTeammate ? 'teammate' : e.workflow ? 'workflow' : e.fork ? 'fork' : 'subagent'
// The model it runs on is known only now: an agent's own definition may pick Haiku.
if (!known) cheap = config.haikuExempt && isHaiku(result.model)
if (known) observeAgentActive(session, started)
else observeSpawn(session, await $.clock.now(), { agentId: started, kind, depth: request.depth, ...(e.name ? { name: e.name } : {}) })
else observeSpawn(session, await $.clock.now(), { agentId: started, kind, depth: request.depth, cheap, ...(e.name ? { name: e.name } : {}) })
}
return result
} finally {
if (reservedAt !== undefined) releaseStart(session, reservedAt, started)
if (reservedAt !== undefined) releaseStart(session, reservedAt, started, cheap)
await publish($, await $.clock.now())
}
}).catch(($, e, next) =>
Expand All @@ -379,7 +385,8 @@ export function register(on: On): void {
if (e.tool === 'SendMessage') {
const target = resumeTarget(e.to, e.message)
if (target) {
const { verdict, reservedAt } = await agentVerdict($, now, { loop, depth: session.agents[target]!.depth, resume: true })
const agent = session.agents[target]!
const { verdict, reservedAt } = await agentVerdict($, now, { loop, depth: agent.depth, resume: true, cheap: agent.cheap === true })
if (verdict.kind === 'deny') {
await journalDenial($, verdict, 'SendMessage')
return { deny: verdict.message }
Expand Down Expand Up @@ -411,7 +418,7 @@ export function register(on: On): void {
if (resumed) {
gatedResumes.delete(resumed.id)
const delivered = result !== undefined && !result.deny && !result.isError
if (resumed.reservedAt !== undefined) releaseStart(session, resumed.reservedAt, delivered ? resumed.id : undefined)
if (resumed.reservedAt !== undefined) releaseStart(session, resumed.reservedAt, delivered ? resumed.id : undefined, session.agents[resumed.id]?.cheap === true)
if (delivered) observeAgentActive(session, resumed.id)
await publish($, await $.clock.now())
}
Expand Down Expand Up @@ -450,6 +457,7 @@ async function loadConfig($: Api): Promise<void> {
const parsed = parseConfig({
AGENT_GUARD: await $.env.get('AGENT_GUARD'),
AGENT_GUARD_JOURNAL: await $.env.get('AGENT_GUARD_JOURNAL'),
AGENT_GUARD_HAIKU_EXEMPT: await $.env.get('AGENT_GUARD_HAIKU_EXEMPT'),
AGENT_GUARD_WINDOW_SECONDS: await $.env.get('AGENT_GUARD_WINDOW_SECONDS'),
AGENT_GUARD_AGENT_MAX: await $.env.get('AGENT_GUARD_AGENT_MAX'),
AGENT_GUARD_ROLLING_MAX: await $.env.get('AGENT_GUARD_ROLLING_MAX'),
Expand Down
30 changes: 30 additions & 0 deletions tests/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,36 @@ describe('agent gate', () => {
})
})

describe('Haiku agents', () => {
const haiku = async ($: Engine, w: ReturnType<typeof world>) => {
w.ran.push('spawn')
return (await $.agent.spawn({ prompt: 'work', description: 'test agent', subagentType: 'general-purpose', model: 'haiku' } as never)) as Record<string, unknown>
}

test('a Haiku agent passes the running and starts caps and does not count against them', async ($, on) => {
const w = world(on, { env: { AGENT_GUARD_ROLLING_MAX: '6' } })
await start($, w, false)
for (let i = 0; i < 4; i++) await spawn($, w)
for (let i = 0; i < 5; i++) expect((await haiku($, w)).agentId).toBeDefined()
expect(String((await spawn($, w)).deny)).toMatch(/4 agents are running on this machine/)
expect(w.store.get(`peer:${SESSION_ID}`)).toMatchObject({ running: 4, starts: [T0, T0, T0, T0] })
})

test('an agent whose own definition runs it on Haiku stops counting once it starts', async ($, on) => {
const w = world(on, { spawnModel: 'claude-haiku-4-5' })
await start($, w, false)
for (let i = 0; i < 6; i++) expect((await spawn($, w)).agentId).toBeDefined()
expect(w.store.get(`peer:${SESSION_ID}`)).toMatchObject({ running: 0, starts: [] })
})

test('AGENT_GUARD_HAIKU_EXEMPT=0 counts Haiku agents like any other', async ($, on) => {
const w = world(on, { env: { AGENT_GUARD_HAIKU_EXEMPT: '0' } })
await start($, w, false)
for (let i = 0; i < 4; i++) await haiku($, w)
expect(String((await haiku($, w)).deny)).toMatch(/4 agents are running on this machine/)
})
})

describe('plan-window gate', () => {
const measure = (percent: number, resetsAt = '2027-01-15T15:00:00.000Z', kind = 'five_hour') => ({
context: { window: 1_000_000 },
Expand Down
4 changes: 3 additions & 1 deletion tests/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type WorldOptions = {
toolResult?: (e: ToolEvent) => Record<string, unknown> | Promise<Record<string, unknown>>
/** What `$.session.compact()` answers, such as `{ skip }` when a hook vetoes it. */
compactResult?: () => Record<string, unknown>
/** The model a spawn reports it runs on when the call names none, as an agent's own definition picks. */
spawnModel?: string
/** Runs inside every agent spawn before it answers, so a test can hold one while it starts. */
onSpawn?: () => Promise<void>
/** What `$.session.usage()` reports for the main context. */
Expand Down Expand Up @@ -123,7 +125,7 @@ export function world(on: On, options: WorldOptions = {}): World {
on('agent.spawn', async ($, e) => {
const agentId = (e.resume as { agentId: string } | undefined)?.agentId ?? `agent-${w.ran.filter((r) => r === 'spawn').length}`
await options.onSpawn?.()
return { model: 'claude-test', agentId } as never
return { model: (e.model as string | undefined) ?? options.spawnModel ?? 'claude-test', agentId } as never
})
on('tool.call', ($, e) => {
if (e.tool === 'AskUserQuestion') {
Expand Down
Loading