Skip to content

fix(agent): let an agent deliver the work it announced, in the same turn - #268

Merged
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/same-turn-announce-then-deliver
Sep 10, 2026
Merged

fix(agent): let an agent deliver the work it announced, in the same turn#268
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/same-turn-announce-then-deliver

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

Follow-up to #263, taking the direction you endorsed there:

plumbing a run id so the gate can exempt a same-turn announce looks like the honest fix, since it distinguishes "this agent is continuing work it announced" from "this agent woke up and decided to talk again" — which is the distinction the gate is actually reaching for.

First, your correction — you were right

I verified it before building on it. postedReplyViaTool is set the moment the intent message reports its side effect (turn.ts:3005), is assigned false only once at declaration (turn.ts:1658), and the relay branch is guarded on !postedReplyViaTool (turn.ts:3212). So once the agent announces, the auto-relay is switched off for the rest of that turn, and my same-turn repro in #263 could never have reached it. #263's real trigger is cross-wake-up, exactly as you described.

The commit message here carries the corrected account.

The case that is still broken

The rules require the intent message, then the result "in this SAME turn" (turn.ts:2229). That second post is the model calling cumora reply itself, and it arrives at the gate with nothing to distinguish it from a monologue:

if (!monologueBypass && cv[0].actor_is_agent && cv[0].member_count > 2) {
  // …my own message is last and younger than 10 minutes…
  return err(`you already posted in ${convoId} ${ageSec}s ago …`)

Verified by reverting the fix — this is the agent's own error text, verbatim:

not ok - the mandated announce-then-deliver flow goes through
  the answer the intent message promised must post: you already posted in
  c-91d43a95 1s ago and nobody has replied yet — you can't post again until
  someone else speaks.

The fix

The gate's own comment says what it is reaching for: "each wake-up is a fresh 'should I respond?' decision with no global stop-signal." A second post from the same run is announced work being delivered. A post from a later run is a fresh decision. Nothing recorded which run posted a message, so the gate could not tell them apart.

  • turn.tsexecutePodTool({ runId })pod-toolstBash → the CLI child env as CUMORA_RUN_ID, beside the CUMORA_AGENT that is already there.
  • cmdReply records <runId>:<posts> per (agent, conversation) in Redis on every successful post, and exempts a second post from the same run.

Capped at two per turn per conversation — announce plus deliver. A third is monologuing again and still needs --continue.

Deliberately not folded into monologueBypass. That flag also disables the freshness preflight (preflightApplies = !monologueBypass && …), and a continuation must still be checked against a room that moved while the agent worked. A test pins that: a peer delivering the same thing mid-work still HOLDs the continuation.

Fail-closed throughout. No CUMORA_RUN_ID (CLI, replay, boot) or a Redis error means no exemption — exactly today's behaviour.

On forging

The exemption is keyed on a run id the model could in principle set itself in its own bash command. That buys it nothing: the only thing it can assert is that it is continuing its own turn, which is what the exemption is for. It cannot reach another agent's record (the key is <agentId>:<conversationId>), and it cannot exceed two posts either way.

Tests

Five added, plus the flag-ordering one rewritten.

  • the mandated announce-then-deliver flow goes through — the fix; red before, with the gate's own refusal text as the failure
  • a later run is still refused — the gate doing its job; green either way
  • a third post in one turn is monologuing again — the cap; red before
  • a continuation is still checked against a room that moved — the guard rail separating this from --continue; green either way
  • with no run id the gate behaves exactly as before — green either way

And the one you flagged:

the flag-ordering test only asserts inside if (wrongOrder.ok) so it can pass vacuously

Correct, and that is the anti-pattern I keep reporting in other people's code. It is now two tests, both asserting unconditionally: a unit test on parseArgs itself (--continue <token> is read as a value flag, so flag-before-body leaves positional without the body at all), and an integration assertion with no condition around it. Verified red by breaking the value-flag branch in parseArgs.

Green: tsc --noEmit (server + renderer), biome lint ., all three scripts/guard-*.mjs, the unit suite (1390 tests, 0 failures), and the full agent-anti-duplicate suite (20/20).

Still not covered

Nothing here changes the "one post per turn is the cap in group chat" line in personas.ts:253, which now under-describes what the runtime permits. That is prompt wording rather than mechanism, and it is your call whether to relax it to match or leave the model more conservative than the gate.

Follow-up to yetone#263, taking the direction @yetone endorsed on that PR.

The operating rules REQUIRE an intent message before long work ("Drafting the
email now"), posted as a SEPARATE `cumora reply`, then the result "in this
SAME turn" (turn.ts:2229). In a group of three or more that intent message is
the room's last message and seconds old, which is exactly what cmdReply's
anti-monologue gate refuses. The flow the product mandates was the flow it
rejected.

yetone#263 could not reach this case, and the repro in that commit message was
wrong about why — my error, corrected by @yetone: `postedReplyViaTool` is set
the moment the intent message reports its side effect (turn.ts:3005) and is
never reset within a turn, and the relay branch is guarded on
`!postedReplyViaTool` (turn.ts:3212). So once the agent announces, the
auto-relay is switched off for the rest of that turn. yetone#263's real trigger is
cross-wake-up: the agent's own message from an EARLIER run is the room's last
message and under ten minutes old when this run's relay goes out. The second
post here is the model calling `cumora reply` itself, and it reached the gate
with nothing to distinguish it from a monologue.

The run id is that distinction, and it is the one the gate is actually
reaching for: its own comment says "each wake-up is a fresh 'should I
respond?' decision with no global stop-signal". A second post from the SAME
run is announced work being delivered. A post from a LATER run is a fresh
decision. Nothing recorded which run posted a message, so the gate could not
tell them apart.

Plumb it: turn.ts passes runId to executePodTool, pod-tools to tBash, tBash
into the CLI child as CUMORA_RUN_ID (beside CUMORA_AGENT, same shape).
cmdReply records `<runId>:<posts>` per (agent, conversation) in Redis on every
successful post, and exempts a second post from the same run.

Capped at two per turn per conversation — announce plus deliver. A third is
monologuing again and still needs --continue.

Deliberately NOT folded into `monologueBypass`: that flag also disables the
freshness preflight, and a continuation must still be checked against a room
that moved while the agent worked. A test pins that a peer delivering the same
thing mid-work still HOLDs the continuation.

Fail-closed throughout: no CUMORA_RUN_ID (CLI, replay, boot) or a Redis error
means no exemption, i.e. exactly today's behaviour.

Also replaces the flag-ordering test @yetone flagged: its assertion sat inside
`if (wrongOrder.ok)` and could pass without checking anything. It now asserts
on parseArgs unconditionally, plus an integration assertion with no condition
around it, and both are verified red.
@yetone
yetone merged commit 4da88e3 into yetone:main Sep 10, 2026
9 checks passed
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