Skip to content

The ambient reply gate discards direct answers to questions qm itself asked, and fails closed #607

Description

@ianTPE

The ambient reply gate discards direct answers to questions qm itself asked, and fails closed

qm was asked to chase a colleague for an answer and report back. The colleague
answered, in the thread, under qm's own question. qm reacted with 👍 and never
reported. The requester was told an hour later that no answer had arrived.

This is not the model that serves the scope making a judgement call — it is a
separate gate, on a separate model, that runs only on messages qm was not
explicitly @-mentioned in, and that defaults to staying quiet whenever it is
uncertain or broken.

(Names below are from a test scenario on my own deployment, translated. The
timestamps, ids and flags are verbatim from Postgres.)

What happened

A private channel. founder asks qm to chase admin and report back within the
hour. Times are Asia/Taipei.

time who message mentions_self handled
22:13 founder "@qm John starts tomorrow, ask @Admin whether his insurance enrolment is done … report back to me in an hour" t t
22:13 qm "@Admin John starts tomorrow, has his enrolment gone through? … @founder I'll report back in an hour"
23:15 qm "@founder an hour's up: no reply from @Admin yet"
23:26 admin "Sorry, didn't see the notification. Filed Friday, effective 8/17, all good." f t
23:50 admin "<@B0BND0YAX4J> did admin answer about the insurance?" f f
00:13 admin "Didn't I reply? Why are you saying I didn't?" f t

The 23:26 row is the one that matters. handled = t — qm ingested it and ran a
turn. That turn's stored result:

{"status":"react","sessionId":"a11e5cb2-…","reactions":["+1"]}

It read the answer to its own question, gave it a thumbs-up, and dropped the
commitment it had made 73 minutes earlier.

Why the gate saw it at all

src/core/orchestrator.ts:398 marks a turn ambient when it arrives without an
explicit mention, and :1540 routes ambient turns through an extra model call
before any real work happens:

if (ambientTurn && deps.harness.models.shouldRespond && ) {
  const decision = await deps.harness.models.shouldRespond({});
  
  if (!decision.respond) {            // :1565
    return reactions ? { status: "react",} : { status: "silent",};
  }

A message that @-mentions qm never reaches this branch. So the reliability of a
tracked hand-off depends on whether the person answering happens to type a
mention — which is precisely the thing qm told them they didn't need to do
("just reply in this thread").

The prompt already specifies the right answer

TURN_DETECTION_PROMPT_HEAD (src/harness/pi-harness.ts:131) covers this case
twice, explicitly:

:140   "it answers a question you just asked, says yes/no/go ahead/that/this in response
        to your prior message"                                              → YES
:159   "is posted in a thread the assistant itself STARTED … Someone replying under your
        own message is almost always talking to you, even without naming you"→ YES

and ships a near-identical worked example:

"yes, send it" after you offered to send something → YES (a direct answer to you, no mention needed)

Admin's message was a direct answer to a question qm posted in that thread. Two
rules and one example all point at YES. The classifier returned REACT.

So this is not a gap in the prompt to be patched with another bullet. A
deterministic condition — qm asked a question in this thread and has not
recorded an answer
— is being delegated to a probabilistic call that can, and
here did, get it wrong.

Two ways the gate fails closed

Uncertainty resolves to silence. pi-harness.ts:188: "When genuinely
unsure, prefer NO — a good colleague would rather stay quiet than barge in."

Sound default for chit-chat in a busy channel. Wrong default when qm is holding
an unfulfilled promise in the same thread, because there the cost of staying
quiet is not a missed pleasantry, it is a broken commitment that nobody is told
about.

A failed detection call also resolves to silence. claude-harness.ts:895:

} catch (error) {
  swallow("claude: detect", error);
  return { respond: false };
}

Any provider hiccup on the detect call — the same class of failure as #602 — silently converts into "don't answer."
Nothing is posted, nothing is surfaced to an operator, and the turn records a
silent status.

Worth noting the gate also runs on judgeModelId, not the model the scope is
pinned to. Whatever model an admin evaluated and selected for a channel is not
the model deciding whether that channel's messages deserve an answer.

Suggested fix

Skip the ambient gate — or force respond: true — when qm has an outstanding
commitment in the thread: it asked a question, promised a report, or set a
deadline that has not been discharged. That state is already known; the thread
session here is literally titled "Chase … status, report back", and the
scheduled report at 23:15 fired from it.

Failing that, at minimum:

  1. catch → { respond: false } should not be indistinguishable from a
    considered "no". Treat a detection failure as "respond" (the safe direction
    when someone is waiting on you), or surface it.
  2. The "prefer NO when unsure" tie-break should invert in threads qm started or
    asked a question in — the prompt already has the rule, it just loses to the
    tie-break.

A related consequence is filed as #608: once the update was not pushed, the
requester could not pull it either.

I have the full transcript, run rows and session ids and can supply them, or
test a branch against a live deployment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions