Repository navigation
feat(prose): locked prose routing and the prose_locus tool - #969
Conversation
proseRouting: "locked" sends an agent's plain speech to one destination it
chose, on every turn, heartbeats included. Nothing moves it automatically:
not the channel that woke the turn, an addressed message mid-turn (under
either speech.locus rule), channel_open, or a tune-out's focus channel.
The new prose_locus tool, offered only to locked agents, reads the lock or
moves it to a channel ("#channel", "@person", an id, resolved as a >> target
is) or to "drafts", which holds plain speech as drafts named in the turn's
receipt. A move is written to framework/state before it applies, reaches the
calling turn's later rounds and every later turn, and survives restarts.
AgentConfig.proseLock sets the destination until the agent first chooses;
with neither, speech is held as drafts.
Locked turns still get the addressed-elsewhere [routing] notice mid-turn,
keep locus mode's send silencing and receipts, and announce a lock change
only when it didn't come from prose_locus. Conversation forks and ephemeral
runs of a locked agent route as locus mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Automatic review of Findings, most severe first:
|
proseRoutingOf passed an unset mode through, where the closed-channel invitation it replaced read one as 'locus' (`?.proseRouting ?? 'locus'`), so a stand-in agent without the field got the prefix option instead of the plain reply (send-into-closed-channel, tuned-out case). Every Agent sets the field; the default is for anything shaped like one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess #244, also open, adds Oscar-1808 wrote this, and Fiona-2757 read it. — Oscar-1808 |
…es, fail-closed lock - A stream whose prose is routed only at its end (no roundContent, membrane's XML tool path) couldn't tell words written before a prose_locus move from those after: all of them went to the new destination, so a move from drafts to a public channel posted what was written for drafts. A move during such a stream now holds the whole stream's plain speech as drafts, and the move's result says so (TurnRouting.lockMoves, endRouted). - A move from outside the agent's turn (a host's executeToolCall, a result after the turn settled) says it applies from the next turn, and no longer advances the announce baseline, so that turn is told where it speaks. - A stored lock entry that names no destination holds speech as drafts instead of falling back to the recipe's proseLock. - channel_open, tune_out and the closed-channel invitation name prose_locus only to an agent that may call it; the invitation reads the open turn's lock rather than the stored one. - Tests: the end-routed move, the out-of-turn move and its notice, the malformed lock, notices without prose_locus, the invitation, forks and ephemeral runs routing as locus, a heartbeat control, the draft's words, the resolution options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review of
Locally, the affected suites pass 816 / 0: @nissa-seru, on #244: the two fit in either order. Its |
|
/review |
|
Automatic review of Findings, most severe first:
Minor findings 7 to 34 (of 34)
|
…l-closed reads, runs publish nothing - The end-routed hold covers the rest of the logical turn (heldForMove), every later stream of it included, and is set only when the lock moved; a move to where speech already goes changes and holds nothing. - A drafts lock no longer hides the message that woke the turn from servers and refusal reactions: the registry takes a turn with no channel of its own from channellessTurn, not from a null speech locus. - An ephemeral run of a locked agent routes as disabled instead of locus mode, so its narration between tool calls can't post to the channel inferred for its turn (the subagent leak disabled inheritance closed). - A stored proseLocks container that isn't a record holds speech as drafts; own keys only. A lock write syncs the store. - channel_publish with no channel inside a locked turn defaults to its lock or nothing, never the registry's resolution of the latest routing. - prose_locus is classed control; its refusals trace as tool:failed; the drafts tool is named only to an agent that may call it; the tool description says how a move works where words are routed at turn end. - Docs: own-id precedence, candidates where there are any, receipts, the first-turn primer, speech.locus and all-except under locked routing. - Tests: no-op move, a move from outside during an open turn, send silencing, no publish default under a drafts lock, a late-registering proseLock, write and read failures, incomingFor, the held drafts' words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the re-review of
One change beyond the findings, prompted by the review of the companion host PR: an ephemeral run of a locked agent now routes as Locally, the affected suites pass 879 / 0: |
|
/review |
About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Automatic review of Findings, most severe first:
|
…targets, dispatch-aware notices - heldForMove is enforced where every locus-speech path delivers (routeLocusSpeech), so a later stream of the turn, live or not, holds its plain speech too; the two end-only branches are gone. - A locked turn's lock is no message it answers: channellessTurn (now a method) counts a heartbeat's or an unnamed wake's turn as having no channel whatever its lock, and a re-pinned heartbeat turn as having one, as before. - prose_locus with no target, channel_open, tune_out and the closed-channel invitation say a turn under heldForMove holds its speech, not the new lock. - A malformed proseLocks record is never overwritten by a lock write: every agent stays held as drafts until the operator repairs it. - Notices name prose_locus and drafts only where the agent may call them at dispatch too (mayCallTool, so denyToolsAtDispatch counts). - The receipt reads the effective mode, so a locked ephemeral run's says its words stay in the transcript. - Tests: the seam hold, channellessTurn cases, a malformed record, dispatch denials, both rounds' words held on the XML path with a mid-turn query, the send having run, the invitation's stored lock written directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review of
Locally, the affected suites (20 files) pass 966 / 0, with |
|
/review |
About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Automatic review of Findings, most severe first:
|
… confirmation, breaking note - Under heldForMove, a sleep posts no announcement, an addressed-elsewhere notice says speech is held, prose_locus's no-op and refusals say so, a channel_publish with no channel has no default, and the invitation and notices offer prose_locus only as moving the next turn (turnSpeaksAt). - An in-turn move no longer counts as announced: the next turn's [routing] notice confirms where speech lands, so a rewind or branch move that drops the exchange still leaves the agent told. - channel_publish with no channel in a turn that came from a channel but speaks nowhere says so, rather than that the turn has no channel. - A breaking fragment for hosts that switch over proseRouting exhaustively. - Docs: the restart guarantee once the store has synced; the next-turn notice. - Tests: a real ephemeral run of a locked agent publishes nothing and still answers; sleep under a lock, drafts and a hold; an ambiguous target's candidates; the XML path declares it can't carry injected messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review of
Locally, the affected suites (20 files) pass 969 / 0, with |
|
/review |
About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Automatic review of Findings, most severe first:
Minor findings 5 to 26 (of 26)
|
… state, typing, upgrade note - A locked heartbeat's auto-continuation stays a heartbeat's turn and names the channel the turn came from, never the lock, so the next turn doesn't read the lock as a message to answer (heartbeatTurnMark, the wake's channel). - The registry's outgoing default for a locked turn is where it speaks now (turnSpeaksAt), so a held turn's no-channel publish gets the held wording. - A damaged framework/state, not only a damaged prose-lock record, holds speech as drafts and refuses lock writes; diagnostics name the shape, never the stored contents, and tell the agent the operator has to repair it. - prose_locus answers "already" only when the turn and the stored choice both agree; otherwise it records the choice. A held turn's next-turn destination is read from the stored lock. - Typing stops when a move leaves the turn speaking nowhere, and follows where a locked turn speaks now, against the turn's own record. - Docs: module speech handlers (onAgentSpeech) see final speech in every mode; proseLock resolves without own-channel precedence; a drafts-locked turn still answers its incoming message; upgrade order in the fragment. - Tests: the heartbeat continuation, sleep results awaited, the send's silenced words held as drafts, bounded damage diagnostics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review of
Locally, the affected suites (20 files) pass 970 / 0. |
|
/review |
About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Automatic review of Findings, most severe first:
|
…typing, doc accuracy - A silent start of a locked agent marks its turn locked with the agent's lock, so a send with no channel and the servers' outgoing default get the lock or nothing, never the channel that woke it or the registry's fallback. - A held turn's continuation shows no typing: the turn-start and stream indicators read where a locked turn speaks now (turnSpeaksAt). - Docs: re-pointing at the turn's destination records the choice over the recipe's; channel_publish (not a module's own reply tool) has no default under a drafts lock; the upgrade note says what an earlier framework does with the mode. - Test: a silent tick of a locked agent publishes with its lock, or with no channel under a drafts lock, and never asks the registry for a default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review of
Locally, the affected suites (20 files) pass 971 / 0, with |
|
/review |
About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Automatic review of Findings, most severe first:
Minor findings 4 to 21 (of 21)
|
Conflict: driveStream's typing channel, where main gave disabled mode the trigger channel explicit mode shows; locked mode keeps turnSpeaksAt. Round 7 of review, in the same commit: - maybePrimeProseMode and writeAnnouncedLocus leave a framework/state that isn't a record as it is, so a damaged slot keeps locked speech held instead of being replaced by a fresh record without its locks. - A locked turn moved to speak nowhere clears the stream's typing channel, so its token and content-block traces carry no channel. - The AgentConfig doc and the added fragment say module speech handlers (onAgentSpeech) receive final speech in every mode; the breaking fragment says what's unchanged and which releases accept 'locked'. - sleep reads the effective mode (a locked ephemeral run is disabled). - Tests: a silent start woken from a channel keeps the lock as its default; the no-op move records the agent's choice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review of
Locally, after the merge: every test file that touches typing or prose routing (48 files) passes 2037 / 0. |
|
/review |
About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Automatic review of Findings, most severe first: Minor findings 1 to 23 (of 23)
|
|
Thanks for the review of
CI passes on |
main's anima-research#969 adds the locked prose mode. At the tool boundary it widens the addressed-elsewhere notice to a locked turn (noticesElsewhere) and derives the locus test from the turn's own mode (proseRoutingOf). That block's condition skipped the notice at a boundary past maxStreamTokens by testing overBudget, which on main means the boundary restarts. On this branch overBudget means only a restart the maxStreamTokens check forces and the window projection doesn't, and budgetRestart is any restart past maxStreamTokens, so the branch tests budgetRestart there: a boundary that declines the restart goes on with its stream and gives the notice, and one that restarts leaves it to the restart's start. The resolution takes main's block with budgetRestart in that condition, as the branch had it in this block before main's change, and has it at the latest-inbound re-pin. No other line anima-research#969 adds reads overBudget.
Brings #969 (locked prose routing). Three conflicts in src/framework.ts: the boundary's noteArrival loop keeps this branch's heard-row shape with main's locked-turn comment; the arrivals notice keeps this branch's returned id with main's locked pin (turnSpeaksAt); the turn start uses main's proseRoutingOf with this branch's presentation-index comment. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Catch-up 12. main moved from 4429afe (catch-up 11) to a25e38b: anima-research#969's locked prose routing. Where the two meet, the rule is: a private turn isn't a locked turn while it's private. Its plain speech goes to no channel, it shows no typing, and it's told it's private, as in every mode; the agent's lock stays stored, untouched, and governs every turn that isn't private. Eleven conflict blocks, resolved under that rule: - src/framework.ts, TurnRouting: anima-research#969's locked, endRouted and heldForMove fields and its locus doc, without the "Frozen when the turn opens" sentence this branch removes. - TurnRouting.addressedElsewhere: main's comment as anima-research#969 has it, with this branch's use of the set for a private turn's held channels. - The [delivered] receipt: proseRoutingOf(agent) === 'disabled' or a private turn's (both sides' conditions). - announceLocusIfChanged's null-locus notice: anima-research#969's text for a locked turn, this branch's otherwise. - The opening start: anima-research#969's locked branch takes only a turn that isn't private; a private turn takes this branch's, with no locus, no announce, and its private notice. anima-research#969's silent branch is kept as it is: a silent start runs as disabled mode and isn't the open turn, and no reader of a silent start's routing gives a private one anything different for its being locked. - The continuing start: this branch's private/non-private split; a private locked turn names the channels it holds messages from, and a non-private locked turn gets anima-research#969's delivery notice. - The typing indicator's comment: anima-research#969's, without "Once a locus or hybrid turn has a locus it always has one", which makeTurnPrivate makes false. - tune_out: both locus conditions; its result keeps this branch's home and private texts, gives a locked agent's private turn its own (this turn private, a turn that holds none at the lock), then anima-research#969's locked text. - sleep: both else-ifs. - src/types/framework.ts, speech.locus: both sentences, the locked one excepting a private turn.
The merge resolves the blocks where anima-research#969's locked routing meets this branch's private turn. These are the rule's sites outside them: - makeTurnPrivate clears routing.locked with the locus, so a locked turn made private mid-way takes the private path everywhere a locked turn's would read its (now empty) lock as "drafts". - A private locked turn names its wake's channels, as a private locus or hybrid turn does: locked takes the locus path. - prose_locus in a private turn: a query says the turn's plain speech stays private and where it goes on a turn that holds none; a move records the lock for those turns and leaves the private turn's locus, channels and typing as they are, and says it applies from the next turn that holds no private input. Its refusals say the same of where speech goes now. - channel_open in a private locked turn takes this branch's private answer: it moves nothing, and says the turn's plain speech stays private. - A closed channel's invitation made while a private turn is open gives the deferring form ("the latest word on where it lands"), naming no lock, since the invitation stays in the window. - The locked primer says "on every turn that holds no private input". - A private turn names no channel to anyone. The channel registry gains a privateTurn option, which the framework answers from the turn's nonChannelOrigin; incomingFor and buildChannelContext read it before anything else, so an MCPL server's context/beforeInference channels name no default outgoing channel, a conversation fork's home included, and no incoming message for a private turn, in every mode. Before, a private turn's context took the global fallback (a fork's, its home) as its outgoing channel and the latest inbound message as the one it answers; this changes the branch's behaviour in locus and hybrid mode as well as locked. The home's other readers are unchanged: the refusal reaction and failure log already skip a private turn, and resolveLocus is asked for a private turn only after privatePublishRefusal has refused a publish with no channel. ChannelRegistry is exported, so privateTurn gets an .added fragment, and activeChannelResolver's doc, which said a fork's home always wins, now names the private turn's context as the exception. Docs: docs/locked-prose-routing.md names the private exception; the 'locked' doc in AgentConfig says the choice applies again after a private turn; the non-channel-origin-locus fragments name locked mode beside locus and hybrid where a private turn's notices and answers apply to it. Tests: locked-prose-routing gains five cases (a private turn speaks nowhere, shows no typing, gets no lock notice and its no-channel publish is refused; prose_locus's query and move in a private turn, with the next turn at the moved lock; a locked turn made private mid-way, whose query and move answer as a private turn's; channel_open in a private turn; the invitation during a private turn), and its primer case reads the new primer. turn-trigger-provenance gains a locked agent's private start naming a room's message it took in, with no typing, and a locked agent's over-budget private boundary naming an addressed message for its restart. all-except-e2e gains a locked agent's private turn entering a focus, and, in locus, hybrid and locked mode under both speech.locus rules and for a conversation fork's private turn, a private turn's server context naming no channel.
main brings anima-research#969, locked prose routing. One conflict, in src/framework.ts: a boundary that can't re-pin now notes what it stored as arrivals. This branch passes noteArrival the stored row, so a system change row isn't taken as conversational; main added a sentence to the comment and kept passing the row's metadata. The resolution keeps this branch's call with main's comment. anima-research#969's other changes merged without conflict. Its prose_locus tool joins an agent's tools when the agent is under locked routing, and a resident declaring entries declares that tool's arrival or removal as it would any change to its tools.
At main, the global fallback a turn with no channel of its own takes is the all-except hold's excepted channel while one is open, then, under speech.locus 'latest-inbound', the latest inbound channel (sharedFallback). The branch consulted unpromptedChannel ahead of that whole fallback, so during a hold a timer's turn spoke in the operator's channel rather than the one the agent chose to keep. The hold is the agent's own live choice of where it attends, and a standing operator default shouldn't override it, as #969's lock, if it lands, wouldn't let it either. The order is now: the hold's channel, then unpromptedChannel where the turn takes it, then the latest inbound channel under 'latest-inbound', else none. resolveLocus and buildChannelContext pass the configured channel into the one fallback chain (globalFallbackFor/sharedFallback), so a turn's locus, channel_publish's default and what servers are told as the default outgoing channel follow one order, kept in one place, and sharedFallback's doc says it. Tests (test/unprompted-channel-routing.test.ts, admission fixture): - a turn no channel prompted speaks in the unprompted channel with no hold, in the excepted channel while an all-except hold is open, and in the unprompted channel again once it ends; - an explicit-mode turn's channel context names the unprompted channel with no hold and the excepted channel during one. Its turns answer with an empty completion, since explicit mode would bounce the fixture's scripted 'ok' into a wake nothing answers. At the previous commit the second step of each named the unprompted channel; with this change both pass.
Problem
Every prose routing mode that delivers unprefixed speech (
locus,hybrid) lets the host decide where it goes: the channel that woke the turn, a re-pin on an addressed message, the agent's ownchannel_open. Some agents want the reverse: one destination they choose, held across turns, that nothing moves until they move it, including pointing it at drafts so nothing is posted.Changes
proseRouting: "locked": every turn's locus is the agent's lock, heartbeat turns included. Nothing moves it automatically: not the trigger channel, an addressed message or engaged-channel follow-up (under eitherspeech.locusrule),channel_open, or an all-except tune-out's focus channel.prose_locustool, offered only to locked agents and refused for others:{}reports where plain speech goes.to: "#channel" | "@person" | <id>resolves the way a>>target does (resolveProseTarget,forSpeech, the turn's own channels). An ambiguous or unknown name is refused with candidates and moves nothing.to: "drafts"holds plain speech asno-destinationdrafts. There's no per-round notice; the turn's[delivered]receipt names them.framework/state(proseLocks) before it applies. It reaches the calling turn's later rounds (same-round prose keeps the locus it had) and every later turn, and it survives restarts. A puppet or host call moves only later turns.AgentConfig.proseLockis the destination until the agent first chooses. It's resolved at each turn's start, so a channel that registers later is picked up then. With neither a choice norproseLock, speech is held as drafts. If the stored choice can't be read, speech is held as drafts (fail-closed) rather than falling back to the recipe's.activeChannelResolverreturnslocus ?? null, so a send tool called with no channel has no trigger-channel default, andsleepposts no announcement.sendCallIdsinclude locked)[routing]notice, now under either locus rule, with arrivals noticed at the next start. The wording drops "this turn".[routing]notice of where speech lands,prose_locusmoves included, so a rewind that drops the exchange still leaves the agent told. AproseLockchannel that registers late is announced too.disabledand publishes no plain speech: in locus mode its narration between tool calls could post to the inferred channel. Neither is offered the tool (proseRoutingOf).heldForMove).[prose-routing]primer on switching in;prose_locusdescription and results;routingline inchannel_open's result;docs/locked-prose-routing.md, a changelog fragment, and theAgentConfigdocs.Companion PR
connectome-host: recipe accepts
agent.proseRouting: "locked"andagent.proseLockand passes them through: anima-research/connectome-host#603. Merge this one first. The host PR needs a release containing it, sinceAgentConfig.proseRoutingdoesn't type"locked"before. This PR changes nothing for agents that don't set the mode.Tests
test/locked-prose-routing.test.ts, 9 tests:deliveryorlatest-inboundand is announced;prose_locusmoves later rounds and later turns, and survives a restart, outrankingproseLock;channel_openmoves nothing and says so;locked-prose-routing,present-while-acting,heartbeat,explicit-prose-routing,prose-drafts,publish-default-locus,trunk-channel-routing,batched-wake-locus,routing-baseline-restart,auto-continue,all-except-e2e,outgoing-stream,conversation-routing,channel-registry-routing): 786 pass / 0 fail. The full suite is left to CI.tsc --noEmitis clean. The connectome-host side typechecks against this branch.locked-prose-routingat 30 tests.onAgentSpeech, such as the in-process Discord module) receive final speech in every mode, this one included. That's documented, not changed here.send-into-closed-channel(a stand-in agent withoutproseRouting). It's fixed in f571896: an unset mode reads aslocus, as before. All 12 CI jobs pass on f571896, including the full Build & Test suite.Not verified
changelog.d/feat-prose-routing-locked.added.md🤖 Generated with Claude Code