Skip to content

Add a per-thread advisor switch - #5

Open
ChrBoebel wants to merge 2 commits into
salemsayed:mainfrom
ChrBoebel:feat/per-thread-advisor-switch
Open

Add a per-thread advisor switch#5
ChrBoebel wants to merge 2 commits into
salemsayed:mainfrom
ChrBoebel:feat/per-thread-advisor-switch

Conversation

@ChrBoebel

@ChrBoebel ChrBoebel commented Sep 3, 2026

Copy link
Copy Markdown

On the stacked commit: this branch carries a minimal version of the SDK 0.4.16
registration fix so it loads standalone. #3 (jonolee-kr, 26 Aug) fixes the same thing
more thoroughly — declarations, a regression test, an engines bump — and predates my
#4. Merge whichever you prefer and I will rebase this branch onto it; it needs some form
of that fix only in order to load at all.

Why

The global Advisor setting is all-or-nothing. A thread where the reviewer is noise — a scratch thread, a long debugging session, a thread that is itself working on Advisor — cannot opt out without switching the reviewer off everywhere. This adds a switch beside the harness selector in the composer.

Behaviour

A thread override wins over the global setting in both directions, so the global value is a default rather than a ceiling: on in a thread while the default is off works, and off in a thread while the default is on works.

The governing rule for "off" is that the advisor never acts on its own initiative in that thread. Anything explicitly asked for still happens — "Review now" and waiting for completion stay ungated. Only the automatic paths are gated.

The switch also appears in the new-thread composer, so a thread can start with the reviewer already off.

Storage

Two tables, both appended to the existing migration list rather than edited into it:

advisor_thread_settings      one row per overridden thread
advisor_new_thread_default   the choice armed in the new-thread composer

retireThread's permanent branch deletes the override with the rest of the thread's rows.

The new-thread case

The new-thread composer has no thread id to write against, and bb.settings is read-only from inside the plugin (get() and onChange(), no setter). So the choice is stored plugin-side and materialized in thread.created.

It is deliberately single-use: the next user-created thread consumes it and clears it. thread.created also ignores threads with an originPluginId, so another plugin's worker thread cannot eat the arm. Resolving it lazily in the gate instead would have applied it to pre-existing threads, and an earlier sticky variant behaved as a second hidden global that shadowed the real setting with no way back.

The gate inside execute()

Worth calling out, because it is the non-obvious one. bb.agents.configure runs at thread.start / turn.submit, and a running provider session's tool set is not hot-mutated — so advisor_review can still be present after the switch is flipped off mid-session. The tool therefore re-checks the gate in execute() and refuses.

The refusal is explicit that it is not an approval, so the agent cannot read a skipped review as a pass:

Advisor is switched off for this thread, so no review ran. This is not an approval — nothing was checked. Finish your work as you normally would.

I reproduced this end to end: with the switch on, the agent called the tool and got a real verdict; after switching the same thread off, the live session still had the tool, called it again, and got the refusal — with the review count unchanged.

CLI

bb advisor enable  [thread-id]
bb advisor disable [thread-id]
bb advisor follow  [thread-id]     # drop the override, follow the global default

bb advisor status gained Advisor default:, New threads: and Thread advisor: lines.

Verification

npm run typecheck clean · npm test 101/101 (83 existing + 18 new) · npm run build succeeds.

The new tests cover both override directions, single-use arming, another plugin's worker thread not consuming the arm, and the execute() refusal in an already-running session. I also checked three of them by mutation — breaking the guard order in thread.created, and the gate direction — and confirmed the intended test fails before reverting.

dist/ left untouched, same reasoning as #4.

`experimental_statusLabels` was folded into `presentation` (labels) in SDK
0.4.16. bb validates registerTool options in the host, not in the SDK bundled
into dist/, so the published artifact is rejected at load time on a current bb
regardless of which SDK it was built against:

  plugin advisor failed to load: registerTool: "experimental_statusLabels" was
  folded into "presentation" (labels) in SDK 0.4.16 (tool "advisor_review")

Observed on bb 0.41.0, 466 ms after installing this commit from git:, followed
by three further load failures.

The option is spread from a constant rather than inlined because the vendored
0.4.2 declarations in types/ do not describe `presentation` yet, so an inline
literal trips TypeScript's excess-property check.
The global Advisor setting is all-or-nothing: a thread where the reviewer is
noise cannot opt out without turning it off everywhere. This adds a switch
beside the harness selector in the composer, for a thread and for a new thread.

A thread override wins over the global setting in both directions, so the
global value is a default rather than a ceiling. Off means the advisor never
acts on its own in that thread; anything explicitly asked for — "Review now",
waiting for completion — still runs.

Two tables, both appended to the migration list:

  advisor_thread_settings     one row per overridden thread
  advisor_new_thread_default  the choice armed in the new-thread composer

The new-thread composer has no thread id to write against, and plugin settings
are read-only from the plugin, so the choice is stored and materialized in
thread.created. It is single-use: the next user-created thread consumes and
clears it. An earlier sticky variant behaved as a second hidden global that
shadowed the real setting with no way back.

The gate is checked at every entry point that can start a review on the
advisor's own initiative, including inside the tool's execute(): bb does not
hot-mutate a running provider session's tool set, so advisor_review can still
be present after the switch is flipped off, and it must refuse rather than
review. It returns an explicit non-approval so the refusal is not read as a
pass.

Adds 18 tests (101 total), covering both override directions, single-use
arming, other plugins' worker threads not consuming the arm, and the refusal
in an already-running session.
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.

1 participant