Skip to content

Count a Bot repeating itself, and hand the count to the boundary - #17

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
jerelvelarde:feat/repeated-action-detector
Aug 19, 2026
Merged

Count a Bot repeating itself, and hand the count to the boundary#17
davidmckayv merged 2 commits into
CopilotKit:mainfrom
jerelvelarde:feat/repeated-action-detector

Conversation

@jerelvelarde

Copy link
Copy Markdown
Contributor

Count a Bot repeating itself, and hand the count to the boundary

The problem

A model that cannot get something to work retries. It clicks the same button, reloads the same page, writes the same file, and nothing in OpenBot counted it. Every attempt is a real action on somebody's live website and a real charge against somebody's model credit, and the trail recorded thirty of them one row at a time, with nothing to say they were the same row thirty times over.

The boundary could not help. Every rule it can express is about the shape of an action, and the thirtieth click has the same shape as the first: a rule able to refuse the thirtieth would refuse the first, and refusing the first is refusing the product. Only the count separates them, and nobody was counting.

The approach

Every governed action already passes through one function in server/src/computer/gateway.ts, which already writes a row for it, so counting there is nearly free. server/src/computer/repeat.ts keys a call on the tool plus the argument saying which thing it acted on — the ref, the key pressed, the file path, the address being opened — and counts identical keys per Bot inside a sliding three-minute window. A bare tool name is deliberately not a key: a Bot working down a form would otherwise look exactly like one stuck on its first field. COMPUTER_REPEAT_WINDOW_MS widens the window for a slower provider, and refuses to start on a malformed value, because a rule that never fires looks exactly like a Bot behaving itself.

The detector observes and does not block. Blocking here would be a second boundary with rules of its own, invisible on the Boundaries page, unanswerable to dry-run, and impossible to relax for the one Bot whose job really is to poll something. Instead the count goes into PolicyContext as repeat.count, taken before the policy is asked so that a rule decides the very attempt that crosses the line rather than the one after it, and a deployment writes repeat.count >= 10 in the deny list it already has. The field is required rather than optional: an absent identifier throws inside CEL and a throwing deny rule denies, so an optional repeat would make one rule about repetition refuse everything.

Crossing 3, 10 or 25 writes one computer.action_repeated row each, carrying the tool, the count and a readable fingerprint (computer_click ref=e9). Each threshold fires once per run, and a run ends only when the window empties completely. The row is not filed as a refusal, and the audit page gives it its own filter and its own words, because a trail that dresses an observation up as a refusal teaches a reader to skim past the real ones. Its insert failure is swallowed and logged: an observation may not cost an action over a moment's trouble at the audit store, and the decision row still goes to the same store, so a store that is genuinely down stops the action there.

What the detector remembers is capped at both levels, because the id it counts against is the one in the request path, checked against a session and nothing else. Nothing still inside the window is evicted to make room, though: least recently seen is exactly the key a Bot going round a long loop is about to make next, and dropping it would report every call in a tight circle as a first attempt. The count is wrong in both directions, as below, and the Boundaries preset, .env.example and docs/architecture.md say so where an operator meets them and suggest dry-run first.

What is not covered

  • It over-fires in one way, and that one costs somebody their Bot rather than their evidence: two calls are the same call when the thing acted on is the same, whatever was typed into it, so ten searches typed into one box are ten repeats and repeat.count >= 10 refuses the tenth. Typed text is left out of the key because it is a password as often as anything else.
  • MCP tool calls are not counted. server/src/plugins/store.ts supplies repeat: { count: 1 } so a rule reads false there rather than unevaluable; counting them means a second detector at a second call site.
  • The count is in memory and per process, so a restart forgets it and two API replicas split every count: a rule about ten attempts fires at twenty or never.
  • It undercounts three ways: a Bot slow enough to spread its attempts wider than the window, one that varies a single argument each round, and one looping around a reload, since a ref only means anything against the snapshot it came from.
  • A Bot already holding sixty-four distinct calls inside the window reports one for anything new until one ages out, and reads and scrolls are never counted at all.
  • Nothing aggregates this into a view, and there is no rate limiting or back-off.

Merge notes

PolicyContext in server/src/computer/policy.ts gains one required field, repeat: { count: number }, placed after actor rather than appended so a branch adding optional fields at the end stays clear of it. Required means every construction site must supply it — two today, the gateway and server/src/plugins/store.ts — and a third added elsewhere fails typecheck until it does. feat/policy-ask-a-person and feat/routines-and-webhook-triggers both change policy.ts and the gateway, so that is the contract to reconcile first. The rest is list-append: an auditEventTypes entry, an audit filter, a Boundaries preset, and additive blocks in server/src/config.ts, .env.example and docs/architecture.md. No schema change, so no migration.

Verification

  • bun run format and bun run format:checkChecked 293 files. No fixes applied.
  • bun run lint — exit 0, with 24 pre-existing warnings in files this branch does not touch.
  • bun run typecheck — app, server and worker all exit 0.
  • bun run test — 628 pass, 5 skip, 0 fail across 70 files. Main's baseline is 594, so this branch adds 34 and takes none away.
  • bun run build — app, server and worker all exit 0.

server/tests/computer-repeat.test.ts is the one new file, holding the detector down on an injected clock: counts rising and staying apart, the window sliding rather than tumbling, each threshold once, a dip that does not end a run, both caps holding, and a loop wider than the cap still being counted. The existing gateway, policy and config suites gain the count reaching the policy, the repeat row landing ahead of the decision it explains, a lost observation row not refusing an allowed action, the CEL rule itself, and the malformed windows that stop the server.

By hand, this branch's frontend was proxied to a running API on a spare port, confirming that the Boundaries preset renders with its cost line and that the "Going in circles" audit filter queries cleanly. Nothing was written to that deployment's database or policy, and the branch has not been driven end to end against a Bot, so the server-side behaviour rests on unit tests.

A model that cannot get something to work retries. It clicks the same button,
reloads the same page, writes the same file, and nothing counted it. Every one of
those attempts is a real action on somebody's live website and a real charge
against somebody's model credit, and the audit trail recorded them one row at a
time with no way to see that they were the same row thirty times over.

The gateway is the one place this can be counted, because it is the one place
every governed action already goes through and already writes a row for.
`computer/repeat.ts` keys a call on the tool plus the argument that says which
thing it acted on, over a sliding three-minute window, and the gateway asks it
before it asks the policy. The count lands in `PolicyContext` as `repeat.count`,
so a deployment can write `repeat.count >= 10` in its deny list and stop a Bot
going in circles with the boundary it already has, on the page it already uses.

The detector does not refuse anything, on purpose. Blocking here would be a
second boundary with rules of its own, invisible on the Boundaries page,
unanswerable to dry-run, and impossible to relax for the one Bot whose job really
is to poll something. It observes; the policy decides.

Crossing 3, 10, or 25 writes one `computer.action_repeated` row each, carrying
the tool, a readable fingerprint and the count. It is deliberately not filed as a
refusal: nothing was forbidden and nothing was stopped, and a trail that files an
observation as a refusal teaches a reader to skim past the refusals that are
real. The audit page gives it its own filter and its own words for the same
reason.

The window costs something and the preset says so. It is time-based, so a Bot
slow enough to spread its attempts wider than the window never trips it, and one
that varies a single argument each time round is ten different calls.
COMPUTER_REPEAT_WINDOW_MS widens it for a deployment whose provider is slow
enough to need that, and refuses to start rather than falling back, because a
rule about repetition that never fires looks exactly like a Bot behaving itself.
…y it is about to need

Three things the detector claimed and did not do.

The outer map was described as bounded by how many Bots a deployment has. It is
not: the id it counts against is the `:botId` in the request path, checked
against a session and against nothing else, because no acting route resolves it
to a row in `bots` first. A signed-in caller looping over invented ids bought a
map, an inner map and an occurrence record each time round, before the policy
was consulted and whether or not any such Bot existed, and none of it was ever
given back. Both levels are capped now and neither drops anything that is still
inside the window, so what is held is the recent past and nothing else.

Eviction was least recently seen, described as the key furthest from tripping
anything. For the one behaviour this feature exists to catch it is the nearest:
a Bot going round a loop of more distinct calls than the cap holds lost each key
exactly one step before it came back, so twenty rounds of a sixty-five step
circle reported every call as a first attempt and no rule about repetition could
fire. Nothing live is evicted at all now. A place is freed when a call ages out
of the window, and while every place is held by something still inside it, a
call the Bot has not made before is not counted rather than something live being
thrown out for it. The cost is a Bot whose first sixty-four distinct calls are
honest work and which only then gets stuck: its loop is invisible until one of
those falls out of the window, at most one window away. A blind spot that clears
itself is worth more than an eviction rule that can be wrong for as long as the
loop lasts, and it buys the other half: a call already being counted can no
longer be pushed out by a Bot doing other things in between.

The observational audit row blocked. `recordAuditEvent` rethrows, and that row
is written ahead of the policy, so a moment's trouble at the audit store refused
every third, tenth and twenty-fifth identical call, on an action nothing
objected to, from the one part of this that is not allowed to refuse anything.
It is swallowed and logged now. The invariant is untouched: the decision row
goes to the same store a few lines below, and a store that is really down stops
the action there.

Also honest about the costs where an operator sees them. The Boundaries preset
named only the two ways the rule under-fires and never the way it over-fires,
which is the failure that costs somebody their Bot: a Bot typing ten different
searches into one box is ten repeats, and the rule refuses the tenth. That
admission, the fact that the count lives in one process and so splits across
replicas, and the fact that MCP tool calls always report one, now reach the
preset, the `PolicyContext` docblock, `.env.example` and the architecture note.
The threshold docblock said a count that falls out of the window and climbs
again reports again; it is the window emptying completely that ends a run, which
is what the code has always done and what the type now says.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the code and run locally. Full suite passes on a clean database (bun run test:ci), format, lint and typecheck clean.

No security concerns: this touches no auth boundary and nothing it adds can be reached without going through the gateway.

@jerelvelarde
jerelvelarde marked this pull request as ready for review August 19, 2026 23:48
@davidmckayv
davidmckayv merged commit 93ff1b1 into CopilotKit:main Aug 19, 2026
3 checks passed
davidmckayv added a commit that referenced this pull request Aug 20, 2026
The approval registry from #15 kept its pending questions in a Map in the process, and the
repetition detector from #17 kept its counts the same way. Both are correct on a laptop and both
stop being correct the moment a deployment runs a second server process, which is the deployment
every part of this is aimed at: several processes behind a load balancer, serving a company.

The failure mode is the reason to take them back rather than leave them and fix them later. Neither
one breaks loudly. A question raised on one process and answered on another is reported as no longer
open, which reads exactly like an expiry. Counts split across processes mean a rule written as
`repeat.count >= 10` never fires, which reads exactly like a Bot behaving itself. A boundary that
silently stops enforcing is worse than one that was never advertised, because the deployment is
relying on it.

The ask lists, the approval surface, the repetition context and the two audit event kinds go with
them. What stays is everything that was already right: the stall watchdog, which tracks streams in
the process that holds them and belongs there, and the client-side work from #16 and #19.

The rule is now on the pull request template, stated before the work rather than at review. Our own
gateway snapshot cache has the same problem and is next.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants