A crowd in one chat
The chat's own model answers, then each other member in order, then the order runs
backwards asking each whether it disagrees, ending at the main model, which either
closes or sends them round again. Design and reasoning: LLeMbas.wiki/Crowd-chats.
THE SPEAKER SEAM, WHICH IS ALSO A BUG FIX
`chat_service.speaker_for` makes the *message* name the answering model and the
chat only the default. That closes a live half-wired feature -- `wake_chat` takes a
model override and `schedule/runner` passes one, and it reached the row and never
the request, so a schedule naming another model got the chat's model wearing the
other one's name.
The seam is wider than `build_request`: `{{model_name}}`, the authored prompt's
model layer, `vision` (where a wrong answer makes the endpoint reject the whole
request), the effort vocabulary (which raises inside the model's own chat template,
and whose refusal narrows every Model row sharing the id), `resolve_tools`,
`context_length` -> `_too_big`, and `ToolContext.model_id`. `resolve_endpoint` may
now only write back `chat.connection_id` when the speaker *is* the chat's model.
WHY N CHAINED REPLIES
`Generation` is one reply's state and `_follow` streams per message, so one
generation cannot stream into nine bubbles and `ensure` would not know which of the
nine it was after a restart. A subagent per speaker cannot work either: its answer
comes back as a tool result and tool results are never replayed, so speaker 3 could
not see speaker 2 -- which is the whole point. Chained, exactly one incomplete row
exists at a time, and `tests/test_crowd_chain.py` asserts that at every
observation.
The round lives on `Message.crowd_json`, not on the chat: the row is the authority,
and chat-level state would describe turns a rewind or a restart had removed.
`crowd.next_turn` is pure, so all eight refusals are tested with no endpoint.
THREE RULES, EACH A BUG WRITTEN THE OTHER WAY ROUND
- `if not _advance_crowd(g): _drain(g)` -- advancing must *suppress* draining, or a
queued human turn puts a second incomplete row beside the next speaker's.
- `_advance_crowd` refuses unless the finishing row is the newest, or regenerating
member 2 creates a second member 3 and two chains race down one turn.
- an error skips one speaker and two in a row end the round: the usual failure is a
small member's window overflowing, and `_drain`'s stop-on-error would kill every
crowd at whichever member is smallest.
Each other speaker's turn is relabelled as attributed user content, which is both
how a model can disagree with words it did not write and how the history keeps
alternating. The per-speaker instruction is payload-only -- as a row it could be
dropped from the request by a `created_at` tie, and every later speaker would answer
it. Compaction, titling and the notification are gated to once per turn; `_inject`
is off during a round; the way back gets no tools and a member is treated as
unattended.
Membership stores the model as text with no foreign key: "Test & refresh" deletes
and recreates Model rows, and a cascade would empty the crowd out of every chat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -182,6 +182,14 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
"Per model and per person, so two models may hold different views and "
|
||||
"nobody sees anybody else's. The person can read and delete it.",
|
||||
),
|
||||
Variable(
|
||||
"crowd_speaker",
|
||||
"The model being quoted",
|
||||
"Inside the crowd fragments only: the name of the model whose words "
|
||||
"follow, or whose turn it is. Blank everywhere else, because it is a "
|
||||
"property of one quotation rather than of a request — which is why the "
|
||||
"legend cannot show you a value for it.",
|
||||
),
|
||||
Variable(
|
||||
"timezone",
|
||||
"Timezone",
|
||||
@@ -1995,6 +2003,106 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"{{transcript}}"
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="crowd.said",
|
||||
label="Quoting another model in a crowd",
|
||||
group=GROUP_TASKS,
|
||||
order=450,
|
||||
variables=("crowd_speaker",),
|
||||
hint="What another speaker's answer is labelled as when it reaches this "
|
||||
"one. It matters more than it looks: sent unlabelled, every earlier reply "
|
||||
"arrives as something *this* model said, so it defends sentences it never "
|
||||
"wrote and cannot disagree with them — which is the whole point of the "
|
||||
"way back. Relabelling is also what keeps the history alternating, which "
|
||||
"several chat templates require.",
|
||||
default="{{crowd_speaker}} answered:",
|
||||
),
|
||||
Fragment(
|
||||
key="crowd.turn",
|
||||
label="A crowd member's turn on the way out",
|
||||
group=GROUP_TASKS,
|
||||
order=451,
|
||||
hint="Added as the last turn when a member speaks on the forward pass. "
|
||||
"The failure to word against is a member that repeats what has already "
|
||||
"been said in different words, which is what makes a crowd feel like an "
|
||||
"echo rather than a second opinion.",
|
||||
default=(
|
||||
"You are one of several models answering this. The answers above are "
|
||||
"quoted with the name of whoever wrote them; yours comes next.\n"
|
||||
"\n"
|
||||
"Add what is missing, correct what is wrong, and say what you would "
|
||||
"have done differently. Do not restate what has already been said to "
|
||||
"show that you agree with it — if you have nothing to add, say so in "
|
||||
"one line and stop. Be brief: somebody is reading all of these."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="crowd.disagree",
|
||||
label="A crowd member's turn on the way back",
|
||||
group=GROUP_TASKS,
|
||||
order=452,
|
||||
hint="Added as the last turn on the backward pass, which is where the "
|
||||
"value of a crowd actually is: everybody has now been heard, and this is "
|
||||
"the chance to object. Worded to ask for disagreement rather than for a "
|
||||
"summary, because a model asked to review will produce a review whether "
|
||||
"it has one or not.",
|
||||
default=(
|
||||
"Everybody has now answered. Read the whole exchange again.\n"
|
||||
"\n"
|
||||
"Do you disagree with anything said above — a claim that is wrong, a "
|
||||
"risk nobody named, an answer to the wrong question? Say so plainly, "
|
||||
"and say which part you mean. **If you have no disagreement, reply "
|
||||
"with one short sentence saying so and nothing else.** Do not "
|
||||
"summarise, do not praise the other answers, and do not repeat your "
|
||||
"own."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="crowd.close",
|
||||
label="The main model's last word, with another round available",
|
||||
group=GROUP_TASKS,
|
||||
order=453,
|
||||
hint="The main model's closing turn when it can still ask for another "
|
||||
"round. Its own fragment rather than a sentence inside the one below, "
|
||||
"because inviting a choice a model cannot express is worse than not "
|
||||
"offering it: on a model without the tools capability there is no "
|
||||
"crowd_again to call, and that is the case the next fragment covers.",
|
||||
default=(
|
||||
"You opened this and you are closing it. The others have answered and "
|
||||
"have had the chance to disagree.\n"
|
||||
"\n"
|
||||
"Write the answer the person actually asked for. Take what the others "
|
||||
"got right, say where you disagree with them and why, and name "
|
||||
"anything still unresolved rather than papering over it. Attribute "
|
||||
"what you took from whom.\n"
|
||||
"\n"
|
||||
"If the disagreement is real and another round would settle it, call "
|
||||
"crowd_again and say what you want them to address. Do not call it "
|
||||
"because the discussion was interesting — every round costs the person "
|
||||
"another wait."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="crowd.close_final",
|
||||
label="The main model's last word, with no round left",
|
||||
group=GROUP_TASKS,
|
||||
order=454,
|
||||
hint="The same turn when another round is not on offer — the round limit "
|
||||
"is reached, or this model has no tools and so cannot ask. It says the "
|
||||
"answer has to be final rather than inviting a choice that would be "
|
||||
"ignored, which is the difference between a feature and a feature that "
|
||||
"looks like one.",
|
||||
default=(
|
||||
"You opened this and you are closing it, and this is the last turn: "
|
||||
"there will be no further round.\n"
|
||||
"\n"
|
||||
"Write the answer the person actually asked for. Take what the others "
|
||||
"got right, say where you disagree with them and why, and attribute "
|
||||
"what you took from whom. Where the disagreement is unresolved, say so "
|
||||
"and say what would settle it — that is more useful than a confident "
|
||||
"answer papered over the top of it."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.compact_lead",
|
||||
label="How a summary is introduced",
|
||||
|
||||
Reference in New Issue
Block a user