From abbffb6bbf27de212e915062005555fd486d5b76 Mon Sep 17 00:00:00 2001 From: Riccardo Bartoli Date: Sat, 24 Oct 2026 03:32:17 +0100 Subject: [PATCH] feat: Haiku agents stay out of the running and starts limits --- CHANGELOG.md | 7 +++++++ README.md | 3 ++- TESTING.md | 2 +- core/agents.ts | 14 +++++++++++--- core/config.ts | 8 +++++--- core/index.ts | 2 +- core/observe.ts | 14 ++++++++------ core/state.ts | 4 +++- hooks/register.ts | 18 +++++++++++++----- tests/agents.test.ts | 30 ++++++++++++++++++++++++++++++ tests/harness.ts | 4 +++- 11 files changed, 84 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f0ec6..690283f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 528bf01..6bfcd58 100644 --- a/README.md +++ b/README.md @@ -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". @@ -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`) | diff --git a/TESTING.md b/TESTING.md index 42d3a37..5860e9f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 | diff --git a/core/agents.ts b/core/agents.ts index 5486d53..df8f0d0 100644 --- a/core/agents.ts +++ b/core/agents.ts @@ -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' @@ -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)) @@ -112,6 +119,7 @@ function agentTrips( config: Config, now: number, limit: LimitReading | undefined, + cheap: boolean, ): Trip[] { const window = config.windowMs const span = duration(window) @@ -119,7 +127,7 @@ function agentTrips( 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})`, @@ -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})`, diff --git a/core/config.ts b/core/config.ts index 7dd9c4b..24da530 100644 --- a/core/config.ts +++ b/core/config.ts @@ -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 +type Field = Exclude type Spec = { field: Field @@ -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 } diff --git a/core/index.ts b/core/index.ts index 90de945..ee91a91 100644 --- a/core/index.ts +++ b/core/index.ts @@ -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' diff --git a/core/observe.ts b/core/observe.ts index 5e8b30c..a293912 100644 --- a/core/observe.ts +++ b/core/observe.ts @@ -90,6 +90,7 @@ export type SpawnFacts = { kind: AgentKind depth: number name?: string + cheap?: boolean } /** @@ -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) } @@ -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 } : {}), } } diff --git a/core/state.ts b/core/state.ts index 028777d..f448299 100644 --- a/core/state.ts +++ b/core/state.ts @@ -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 = { @@ -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): boolean { diff --git a/hooks/register.ts b/hooks/register.ts index 49b3784..2ad6526 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -30,6 +30,7 @@ import { heldForQuestion, helpText, highestLimit, + isHaiku, isJournalRow, journalKey, livePeers, @@ -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) => @@ -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 } @@ -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()) } @@ -450,6 +457,7 @@ async function loadConfig($: Api): Promise { 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'), diff --git a/tests/agents.test.ts b/tests/agents.test.ts index ebe3327..24a57b4 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -206,6 +206,36 @@ describe('agent gate', () => { }) }) +describe('Haiku agents', () => { + const haiku = async ($: Engine, w: ReturnType) => { + w.ran.push('spawn') + return (await $.agent.spawn({ prompt: 'work', description: 'test agent', subagentType: 'general-purpose', model: 'haiku' } as never)) as Record + } + + 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 }, diff --git a/tests/harness.ts b/tests/harness.ts index 0ffc753..233bfb4 100644 --- a/tests/harness.ts +++ b/tests/harness.ts @@ -23,6 +23,8 @@ export type WorldOptions = { toolResult?: (e: ToolEvent) => Record | Promise> /** What `$.session.compact()` answers, such as `{ skip }` when a hook vetoes it. */ compactResult?: () => Record + /** 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 /** What `$.session.usage()` reports for the main context. */ @@ -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') {