Skip to content

Gate routing on a clarification phase so agents stop jumping the gun - #584

Open
QuanCheng-QC wants to merge 8 commits into
developfrom
feature/clarify-phase-gate
Open

Gate routing on a clarification phase so agents stop jumping the gun#584
QuanCheng-QC wants to merge 8 commits into
developfrom
feature/clarify-phase-gate

Conversation

@QuanCheng-QC

@QuanCheng-QC QuanCheng-QC commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Gate routing on a clarification phase so agents stop jumping the gun

The problem, and where it came from

Reported from workspace usage: with no orchestration configured, agents jump
the gun — the requirement is still being clarified and the RD agent has
already started writing code.

It is not a bug in one place. Routing is a stateless per-message decision
biased towards "somebody must answer now"
:

  • the LLM router picks whoever's description best matches the topic;
  • for a human message it is forbidden to answer "nobody" (the prompt says so,
    and there is a fallback that re-routes a stop verdict to the master);
  • nothing in the system represents "we are still figuring out what to build".

So a half-specified request goes straight to the agent that builds things. No
prompt can fix this reliably — the agent that gets woken has already won the
turn, and telling it "please don't build yet" is a request, not a constraint.

How to reproduce (on develop)

Three agents in one thread, dynamic mode, no leader. Descriptions matter —
the router picks on them:

agent description
pm 产品经理,负责需求澄清…不写代码,不做技术实现。
dev-be 后端开发工程师…拿到需求就开始写代码、建文件、跑命令。
dev-fe 前端开发工程师…拿到需求就开始写代码、建文件。

Then send a deliberately underspecified request and answer only part of the
follow-up:

我们要做一个用户登录功能,支持手机号和邮箱两种方式,你们看下怎么搞,尽快。
先做手机号验证码登录,第三方登录以后再说。
验证码有效期 5 分钟吧,其他你们定。

Requires ROUTER_LLM_API_KEY (or ANTHROPIC_API_KEY) — without it the LLM
router is off and routing falls back to a deterministic mention/master path
where this symptom does not appear.

Before → after, same prompts, same three agents

Both runs were done manually against a local stack.

develop this branch
Who spoke during clarification dev-be / dev-fe, immediately only the PM — zero dev messages
Code produced before the user confirmed anything a complete Go backend + a vanilla-JS frontend none
Clarification turns before work started 0 — the PM degraded into after-the-fact acceptance 4 turns, ending in a 10-item signed-off spec
When "graphical captcha" surfaced after full delivery, as PM acceptance "missing item #1" item #6 of the clarification checklist, before any code
Rework caused by that 2 rounds (backend adds captcha → frontend has to catch up) 0
Releasing the gate n/a owner released it itself once the user typed 确认, then dispatched

Connector log from the gated run — four wake-ups during clarification, all of
them the phase owner, and the two dev connectors never received the messages
at all:

08:51:52 [claude-test-001]: Phase gate active (owner) for message 9641f890…
08:52:19 [claude-test-001]: Phase gate active (owner) for message 2b616f4f…
08:52:36 [claude-test-001]: Phase gate active (owner) for message c4dd32e4…
08:55:09 [claude-test-001]: Phase gate active (owner) for message 1c66eee0…

The captcha row is the point of the whole PR: the same requirement costs one
question up front, or two rework rounds after the fact.

What was added

A per-channel phaseopen (unchanged behaviour) / clarifying /
building — applied last, after whichever orchestration mode has picked
its targets, so it constrains every mode uniformly:

target while clarifying treatment
phase owner / channel master untouched — clarification is their job
a non-gatekeeper the sender @mentioned kept, but forced into plan mode
any other non-gatekeeper dropped, turn goes to the owner
everything dropped and the owner is the sender turn ends (no self-loop)

Enforced in the backend, not requested in a prompt. The router prompt is told
about the phase only so its verdicts stay consistent with what the gate will
allow, instead of being overridden after the fact every turn.

Being woken is not permission to build. The routed message carries phase,
phase_owner and target_modes. BaseAdapter appends a role-specific
directive and sets a per-channel mode override for exactly that message; the
Claude adapter reads it wherever it reads its own mode, so a gated wake-up
genuinely launches with --permission-mode plan and a read-only tool set.
Mirrored in the Python adapter stack.

Release is the user's call — a "Requirement confirmed" button in the thread
header, plus a workspace_set_phase tool the owner may call after the user
confirms, never on its own judgement.

On by default for new multi-agent threads. The new-thread dialog offers
"Clarify requirements before execution", checked, with an owner picker. Phase
and owner ride along with channel.create rather than a PATCH afterwards — a
thread that is ungated for even a moment can have its first message routed to a
builder. Existing threads are untouched (phase defaults to open).

Blast radius

A. Only affects threads that are actually gated

_apply_phase_gate returns immediately unless phase == 'clarifying';
_phase_router_block returns ""; the adapter directive is skipped when the
message metadata has no phase. A thread at open routes exactly as before.

B. Affects every thread — this is where review time belongs

These are not phase-gate features; they are shared paths this PR had to change,
and they change behaviour for gated and ungated threads alike.

change who it affects now
DELETE /v1/workspaces/{id}/members/{name} now emits network.agent.remove instead of deleting the row every agent removal from the UI: it becomes a soft delete with a status='removed' tombstone, reassigns workspace + per-channel master, and a second DELETE now returns 404 instead of 200
master promotion uses .scalars().first() instead of .scalar_one_or_none() previously, removing the master from a workspace with ≥2 remaining agents raised MultipleResultsFound and aborted the whole removal
channel.leave now clears master_agent when the master leaves before, master and fallback routing kept aiming at an agent that had walked out of the channel
_composeFinalResponse keys off pp.spawnMode instead of this._mode any Claude agent whose process was spawned in plan mode now gets its plan file attached based on how it was spawned, not on the mode at reply time
_dispatchMessage calls _applyPhaseGate on every message no-op for ungated threads, but it is on the hot path for all of them

C. Schema

Migration 028 adds two nullable-safe columns to channels
(phase TEXT NOT NULL DEFAULT 'open', phase_owner TEXT NULL). Idempotent
(guarded by an inspector check), reversible, no backfill, no data rewrite.

D. Not touched

Message delivery, event persistence, DM routing, the master / workflow
orchestration modes themselves (the gate wraps them, it does not modify them),
and single-agent threads.

If you only have 15 minutes

  1. _apply_phase_gate() in workspace_mod.py — the whole routing rule, ~60 lines.
  2. The DELETE /members/{name} rewrite in routers/workspaces.py — the largest
    behaviour change for existing users, and the one least related to the feature.
  3. _runMessage / _modeFor in adapters/base.js — the per-channel override is
    set and cleared around exactly one message; a leak here would silently pin an
    agent into plan mode.
  4. The phase + phase_owner validation block in update_channel() — this is
    where "never persist a gate nobody can hold" is enforced.

The invariant everything degrades to

A gate is opened and closed only by a human. Any anomalous state degrades to
"someone answers, nobody builds" — never to unrestricted routing.

Covered cases: an owner that was never valid; one removed from the workspace;
one that left the channel; one whose daemon crashed (liveness, not just
membership — a dead daemon leaves status='online' forever); a stale owner
blocking the user from leaving the gate; both member-deletion endpoints; and a
dialog race where a selected agent goes offline mid-dialog.

How it was verified

  • Backend: 40 phase-gate tests + 12 channel-phase API tests; test_llm_router
    and test_workspaces green (124 in the touched suites).
  • Connector: 799 pass. Python SDK: 20 pass. Frontend tsc clean.
  • Plan-mode enforcement and gate application are mutation-checked: reverting
    the override / removing the gate call makes those tests fail, restoring makes
    them pass. Mode bookkeeping alone could not tell enforcement apart from a no-op.
  • Manual A/B against a live workspace with three Claude agents — the runs
    summarised in the before/after table above.
  • Full backend suite failure set is identical to develop's (environment:
    /tmp/openagents_files ownership, no local Postgres, pre-existing init.sql
    drift in test_migration_schema).

Known issues and follow-ups

  1. The owner can release the gate to the wrong state. In the manual run the
    owner announced "switched to building" but the channel ended up at open.
    Both release the thread, so nothing broke, but open means "this thread is
    never gated again" and the header falls back to the "Clarify first" entry
    point, which reads as though the gate had never been on. The
    workspace_set_phase tool description offers all three values to the model;
    the enum exposed to agents should be narrowed to clarifying / building,
    leaving open to the human-only menu item. Not fixed in this PR.
  2. Runtime enforcement is Claude-only. The other adapters (8 JS, the rest of
    Python) receive the plan directive as prompt text with no read-only runtime
    constraint. UI copy says "kept in planning mode" rather than "cannot build"
    for exactly this reason. Follow-up order by usage: codex, opencode, cline.
  3. The gate is a no-op once the thread is building. The separate failure
    mode where agents echo "done / delivered / 👌" at each other after delivery
    is unaffected by this PR and needs its own fix in the router's stop logic.
  4. workspace/frontend has no test runner, so the dialog's default-checked
    behaviour is covered by type checking and the backend contract tests, not by
    an automated UI test.
  5. test_migration_schema already fails on develop — the insforge
    0001_initial_schema.sql is stamped at 015 and 13 migrations behind. Not
    touched here.

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openagents-workspace Ready Ready Preview Aug 5, 2026 8:41am

Request Review

Routing was a stateless per-message decision biased towards "somebody must
answer now": the router picks whoever's description best matches the topic,
and for a human message it is forbidden to answer "nobody" (there is even a
fallback that re-routes a `stop` verdict to the master). So a half-specified
request went straight to the agent that builds things, and it started
building while the requirement was still being clarified.

Add a per-channel phase — open (default, unchanged behaviour), clarifying,
building — that every orchestration mode passes through last:

  • a gatekeeper target (phase owner, else the channel master) is untouched;
  • a non-gatekeeper the sender explicitly @mentioned is kept but forced into
    PLAN mode, so it can answer "is this feasible?" without implementing;
  • any other non-gatekeeper target is dropped and the turn goes to the
    owner — the case that produced the bug. When everything is dropped and
    the owner is the sender, the turn ends rather than self-looping.

The gate is authoritative in the backend rather than advice in a prompt, so
it holds regardless of which mode the thread uses or what the router model
decides; the router prompt is told about the phase only to keep its verdicts
consistent with what the gate will allow.

Connector side, the routed message carries `phase`, `phase_owner` and
`target_modes`. BaseAdapter appends a role-specific directive (append, not
prepend, so channel auto-titling still sees the user's words) and sets a
per-channel mode override for exactly that message; the Claude adapter reads
it where it reads its own mode, so a gated wake-up genuinely runs with plan
permissions and read-only tools, not just an instruction it might ignore.
Mirrored in the Python adapter stack so the two ports don't diverge on this.

Release is the user's call: a "Requirement confirmed" button in the thread
header, plus a `workspace_set_phase` tool the owner can call once the user
confirms — never on its own judgement.
The gate shipped in a state where the two paths that matter most could
silently do nothing.

An ownerless gate was persistable, and it was the default case. Threads made
from the agent picker deliberately have no master, so the one-click "Clarify
first" stored phase='clarifying' with owner=None; _phase_gatekeepers then
returned nothing and routing passed through untouched while the header said
"Clarifying". A gate that looks enforced but isn't is worse than none. Phase
and owner are now validated together before either is written: an unknown or
removed owner is a 400, an owner not yet in the channel is joined to it
rather than rejected, and clarifying without any resolvable owner is refused.
The control sends both fields in one request instead of hoping the backend
can derive one.

Gatekeepers are also re-validated at routing time against live membership,
because a write-time check does not survive the agent later being removed or
leaving the channel. A gate nobody can hold now falls open with a warning
rather than redirecting to a name no connector answers to — an unenforced
gate is visible and recoverable, a stranded conversation is neither. Removal
and channel-leave hand the gate to the master, or open the thread when there
is no one left; picking an arbitrary survivor is not ownership.

Fixing that repair path surfaced a pre-existing crash next to it: promoting
the next master used scalar_one_or_none() over a multi-row query, so removing
the master with two or more agents left raised MultipleResultsFound and
aborted the whole removal.

Python's Claude adapter accepted the per-message plan override into
BaseAdapter and then ignored it, building its system prompt and permission
flags from self._mode — a gated agent still launched with
--dangerously-skip-permissions and Write/Edit/Bash. Both now read
_mode_for(channel).

The tests that were supposed to cover this only asserted mode bookkeeping,
which cannot distinguish enforcement from a no-op. Both ports now assert on
the argv the adapter would spawn; both suites were mutation-checked by
reverting the override and confirming the new assertions fail.
… write paths

Fail-open was the wrong call. When a clarifying channel has no gatekeeper
left, returning the targets untouched wakes the builder with execute
permissions — precisely the behaviour this feature exists to prevent, now
reachable through an anomalous state rather than the normal one. Failing
closed instead would strand the human with no reply. Neither is necessary:
the per-target mode mechanism was already there, so an unenforceable gate now
keeps whoever was targeted but downgrades every one of them to plan. The
thread stays responsive and nobody builds against a requirement that is still
officially unsettled. `phase` is stamped even without an owner so the woken
agent is told why it is constrained.

Channel creation checked only that the owner appeared in the caller-supplied
participants list, which proves nothing — participants is untrusted payload.
A create could therefore persist phase=clarifying with an owner that is not a
workspace member at all, or one already removed. It now runs the same
membership validation PATCH does.

An agent that leaves a channel while being its master stayed recorded as
master, so both master-mode and fallback routing kept aiming every later
message at someone who had walked out. The gate repair sat directly on top of
that, and reset the phase while leaving the stale master behind, so opening
the gate handed the thread straight back to the departed agent. Leaving now
clears the channel master first, then repairs the phase owner. The stale
master is pre-existing on this path (verified against develop) but is
repaired here because the phase repair depends on it; the test asserts the
next human message actually reaches a live agent.

The new Node argv test isolated the working directory but not the home
directory, while the MCP command builder writes its config under
os.homedir() — leaking files into the developer's home and failing outright
where HOME is read-only. It now stubs os.homedir for the duration.

The plan directive no longer claims the agent was @mentioned, since it now
also covers being pulled in without one.
… a thread

DELETE /v1/workspaces/{id}/members/{name} hard-deleted the row instead of
going through network.agent.remove, so it skipped everything removal owes the
rest of the system: the status='removed' tombstone that stops a still-running
daemon from re-joining (issue #347), workspace and per-channel master
reassignment, and the clarification-gate repair added last round. Deleting an
owner through this endpoint left channels pointing at an agent that no longer
existed — the one path around the lifecycle fixes. It now emits the same
event as POST /v1/remove, keeping its own existence check so the documented
404 still holds.

Owner validation ran on every phase PATCH, including the ones leaving the
gate. A thread whose owner had been deleted therefore answered 400 to both
"Turn the gate off" and "Requirement confirmed": the only way out was to
appoint a new owner first, on a thread the user was trying to abandon. A
valid owner is now required only for a resulting clarifying phase, and an
explicitly supplied bad name is still an error; a stale inherited one is
cleared on the way out.

The control resolved the owner by string truthiness, so a stale name rendered
a confident "Clarifying · @ghost" while routing had already fallen back to
the master or to the plan-safe path where nobody holds the floor. Owner and
master are now resolved against the thread's actual agents. The control also
stayed hidden below two agents, which is precisely the degraded state a human
has to repair; it now shows whenever the thread is gated.
The gate existed but nothing turned it on, so the reported behaviour was
still the default: create a thread from the picker, ask for something
underspecified, and the router hands it to whichever agent matches the topic.

Multi-agent threads now offer "Clarify requirements before execution",
checked by default, with an owner picker. The owner preselects the workspace
master when it is among the participants and otherwise demands an explicit
choice — guessing would often land on the agent that builds things, and a
gate owned by the builder is not a gate. The option only appears once two
agents are selected, since a single-agent thread has nobody to hold the floor
against.

Phase and owner ride along with the channel.create event rather than a PATCH
afterwards. A thread that exists ungated for even a moment can have its first
message routed to a builder, which is precisely the window this is meant to
close; a test asserts the opening message is already gated, with the builder
listed first so an ungated fallback would have picked it.

Unconditional default-on was the alternative and was rejected: picker-created
threads have no master, so it would have had to invent an owner, and a
requirement that is already settled would pay a confirmation round every
time.

Also: DELETE members now treats a tombstone as absent. Removal became a soft
delete last round, so the second DELETE found the row left by the first,
emitted another removal event and answered 200 — quietly dropping the 404
this endpoint returned back when it hard-deleted.
An explicit request for a gate could still produce an ungated thread. If the
owner went stale between the client listing agents and channel.create running
— removed, taken offline — creation quietly fell back to open, while the
client painted the thread "clarifying" from its own request. The first
message then routed to a builder in execute mode, unprotected, until
discovery corrected the view seconds later. That is the exact failure this
feature exists to remove, reintroduced through the anomalous path.

An explicitly requested gate is now never dropped. When the owner cannot be
resolved the channel is created gated but unowned: routing already degrades
that to "everyone answers in plan mode", so the thread stays responsive and
nothing can be built, and it renders as "Clarifying · needs an owner" until a
human names one. Rejecting the event would have been the other way to avoid
fail-open, but it throws away the thread the user just set up.

The same reasoning applies to the repair path, which until now reset the
phase to open when an owner was removed with no master to inherit it. Losing
an agent is not the user deciding the requirement is settled. It now clears
the owner and keeps the gate, so only a human ever removes one.

channel.create returns the phase and owner it actually persisted, and the
client renders from those instead of from what it sent.

The dialog derived the gate from `selected`, which can name an agent that
discovery has since reported offline, while it submitted participants
filtered against the current online list — so it could offer a gate and then
submit an owner that was not among the participants. Both now come from the
same live set.

UI copy softened from "cannot start building" to "kept in planning mode":
outside the Claude adapters that constraint is prompt-level, and the wording
promised more than every runtime enforces.
Two ways the gate could still lapse without anyone being told.

A crashed daemon leaves status='online' in the database indefinitely, and
gatekeeper validation only excluded 'removed'. An owner whose connector had
died therefore kept the floor: routing dropped or redirected everyone else to
it, the plan-only fallback never engaged because a "gatekeeper" existed, and
the thread sat silent while looking perfectly healthy. Gatekeepers are now
filtered by _member_is_online, the same liveness rule the rest of routing
uses. phase_owner stays in the database — it records who owns the
requirement, not who is connected this second — so ownership resumes by
itself once the daemon heartbeats again, and until then the thread answers in
plan mode instead of going quiet.

Round two chose membership over liveness here on the grounds that silence
beats wrong work. That trade no longer exists: an unowned gate is now
plan-only rather than open, so there is nothing to gain by routing into a
dead mailbox.

The fixtures had modelled agents as status='online' with no heartbeat ever
recorded, a state no real agent is in; they now carry one.

In the new-thread dialog, the gate's visibility was derived from who is
online rather than from what the user selected. A selected agent going
offline mid-dialog made the whole option vanish, and Create — still enabled —
produced a smaller, ungated thread with no indication anything had changed.
Intent and applicability are now separate: the option stays visible from the
selection, and when too few of those agents are still online the dialog says
which ones dropped and blocks Create until the user picks someone else or
unchecks the gate deliberately. Create is also blocked outright when every
selected agent has gone offline, which used to produce an empty thread.

Also corrects the _phase_gatekeepers docstring, which still described the
old "gate inert / deliberate fail-open" behaviour — the exact opposite of
what the code now does, and a trap for whoever edits it next.
develop localised the workspace into English and Simplified Chinese while
this branch was open, and typed zh-CN against the English catalogue so a
missing key is a build error rather than a blank string. The gate landed
before that and carried its own hardcoded English, which would have been
the only untranslated corner of the chat header and the new-thread dialog.

Move every user-facing string in the phase control and the dialog's
clarify block into the catalogues, and take the two other conventions the
rebuilt UI settled on: the Select component instead of a bare <select>,
and plural forms via the `{one, other}` objects rather than a ternary on
the count.
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