Skip to content

An attention inbox: refusals and stalls, surfaced instead of waited on - #255

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
anygivenfriday:feat/attention-inbox
Aug 26, 2026
Merged

An attention inbox: refusals and stalls, surfaced instead of waited on#255
davidmckayv merged 2 commits into
CopilotKit:mainfrom
anygivenfriday:feat/attention-inbox

Conversation

@anygivenfriday

Copy link
Copy Markdown
Contributor

What this changes

A boundary refusal or a stalled run is recorded and then waits for a person to happen to look — at
the right channel, or at the audit page only an administrator has. The trail knows; nobody is told.

Attention, in the sidebar for everybody, lists the refusals (computer.action_refused,
mcp.call_rejected) and stalls (agent.stream_stalled) nobody has handled yet, scoped per item to
the Bots this person may use via the same canUseBot the roster and the computer use — an
administrator sees everything the way they see every Bot. A count badge draws only when nonzero.
Marking an item handled clears it for everyone and records who did.

It is a view over the trail, not a second record of it. Those rows are already written
transactionally by the gateway and the stall guard, so the inbox cannot miss one: no dual write to
drift, nothing new on the action path, and the append-only trail untouched. The only state it owns
is the resolution — one table (attention_resolutions), ids by value with no foreign keys, for the
trail's own documented reason (core.ts, actorUserId).

Deliberately not under /api/admin: the audit page is the administrator looking back; the inbox is
the working person being told now.

One subtlety worth review attention: which Bot a row is about is not where it looks. A tool
rejection's target is the tooltargetType: "mcp_tool", targetId the ref — and its Bot
travels only in the payload. Reading targetId unconditionally called a refusal's Bot
google-drive/search_files, which canUseBot correctly denies, which hid every tool rejection from
exactly the person it was for. botOf reads targetId only for computer and agent rows, and a
row that cannot name its Bot is dropped rather than shown to everybody.

Where it runs

  • New state that outlives a request? One table, attention_resolutions
    (audit_event_id unique, resolved_by, resolved_at), migration 0016. Nothing held in
    process; the view is computed per request.
  • What happens on the second replica? The same answer: both compose the same trail and the
    same resolutions from Postgres. A resolution written on one replica is subtracted by the
    other's next read.
  • Anything serialised? Resolving. By unique index + onConflictDoNothing, not
    check-then-write: the second writer's insert conflicts and it reads back who won —
    alreadyResolved: true with the standing attribution.
  • Anything fanned out to a browser? No. The app polls at 15s with refetch-on-focus, the
    same shape as the existing grant polls. A push upgrade over the channel socket is a natural
    follow-up and deliberately not in this PR.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway — nothing here acts.
  • New refusals and new failures each write a row — none are created; the inbox reads the rows
    the gateway and stall guard already write. Resolution attribution (who, when) is recorded in
    its own table.
  • Nothing new is trusted from the client — the only client input is an event id, answered from
    the server's own trail; a made-up id and a row of another kind answer the same 404, so the
    endpoint cannot probe what the trail holds.

Changelog

  • One entry under Unreleased.

Proof

Unit (attention-view.test.ts, 7 cases): the gateway's own refusal reason used whole; the tool
rejection's Bot read from the payload, never the ref; a Bot-less row dropped rather than shown to
everybody; resolved rows subtracted; foreign event types ignored. Integration
(attention-store.integration.test.ts): the resolve race — first writer wins, second told who —
and resolvedAmong answering exactly the resolved subset.

Suites: server 1088 pass / 0 fail; app 158 pass, tsc clean, production build clean.

Live, against a deployment whose trail held real mcp.call_rejected rows:

GET /api/attention                 → 8 items, each naming its Bot and the decision's own sentence
POST .../resolve                   → {resolvedBy: "dev-local-user", alreadyResolved: false}
POST .../resolve (again)           → {resolvedBy: "dev-local-user", alreadyResolved: true}
GET /api/attention                 → 7 items; the resolved id absent
POST .../<random uuid>/resolve     → 404

In the browser: sidebar shows "Attention · 8"; pressing Resolve on a row drops the list to 7 and
the badge follows through invalidation.

A boundary refusal or a stalled run was recorded and then waited for somebody
to happen to look — at the right channel, or at the audit page only an
administrator has. The trail knew; nobody was told.

The inbox is a view over the trail, not a second record of it. Refusals
(computer.action_refused, mcp.call_rejected) and stalls (agent.stream_stalled)
are already written transactionally by the gateway and the stall guard, so
deriving the inbox from those rows means it cannot miss one: there is no dual
write to drift, and nothing new runs on the action path. The only state it owns
is the resolution — who marked a row handled, and when — in a table beside the
append-only trail rather than in it, ids by value with no foreign keys for the
trail's own documented reason.

GET /api/attention composes recent rows minus resolutions, then scopes per item
by the same canUseBot the roster and the computer use; an administrator sees
everything the way they see every Bot. Not under /api/admin: the audit page is
the administrator looking back, the inbox is the working person being told now.

POST /api/attention/:eventId/resolve marks one handled for everyone, with
attribution. First writer wins by unique index rather than check-then-write,
and the second presser is read back who got there first. Only a row of the
three attention kinds resolves; anything else answers the same 404, so the
endpoint cannot be used to probe what the trail holds.

Which Bot a row is about is not where it looks: a tool rejection's target is
the TOOL — targetType "mcp_tool", targetId the ref — and its Bot travels only
in the payload. Reading targetId unconditionally called a refusal's Bot
"google-drive/search_files", which canUseBot correctly denies, which hid every
tool rejection from exactly the person it was for. botOf reads targetId only
for computer and agent rows, and a row that cannot name its Bot is dropped
rather than shown to everybody.

In the app: an Attention page listing what is open with Resolve on each row,
and a sidebar entry with a count badge drawn only when nonzero.
# Conflicts:
#	CHANGELOG.md
#	server/drizzle.config.ts
#	server/drizzle/meta/0016_snapshot.json
#	server/drizzle/meta/_journal.json
#	server/src/app.ts
#	server/src/index.ts
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