Skip to content

Commit 9bfbbad

Browse files
yao pengfeiclaude
andcommitted
refactor(contracts): finish the judge disclosure, and stop a sentence becoming eighteen
Review on #495 (LivXue), at 3a1bf07. Five of its findings; two it raised are answered rather than taken, below. The judge disclosure had reached two of its four homes. The two it had not are ``ActionModule.ask_judge`` on the paper and ``DefaultAction.ask_judge``, and those are the ones a reader of the role protocol meets first. They survived because they phrase the claim without naming anything the earlier fix touched: "composed from each participant's judge", "asked after whatever the caller passes". There is one caller and it passes nothing. Both now say so. The test meant to pin that could not fail for the reason it was written. It collected what the participant was asked and then asserted only that the Charter still refused -- true before this PR and true after any wiring anyone might add -- and it built a ``ParticipantHook`` it then dropped, so the factory never ran and the collection could not have been non-empty whatever the production code did. It now asserts the argument the registry hands the role, which is the fact the three docstrings rest on. Mutation-checked by wiring a participant through ``_verifier_refusals``: the old assertion stays green, the new one turns red. ``compose_judge`` exploded a one-sentence refusal into one refusal per character: a bare ``str`` satisfies ``Sequence[str]`` structurally, so nothing in the annotation or a type checker objects. ``charter_code.run_judge`` coerced it where this composition used to live and moving the composition out a layer did not bring the handling along. Unreachable today -- the Charter path is normalised before it arrives and no bundled plugin implements the verb -- but this is the seam meant to carry generated judgements, and a bare string is a plausible thing for one to answer. Coerced, with a test, mutation-checked. Two records corrected. The type-only ``raven.contracts`` -> ``raven.agent`` edge, the first in the tree, is the price of moving ``Verdict`` and ``Intake`` off the paper; it costs nothing at import time and lint-imports does not see it through ``exclude_type_checking_imports``, so it is now written beside the contract it bends rather than left to be discovered. And the ledger entry for the 3,200 -> 3,410 bump had been renamed but not refreshed, so it read as a present-tense description of a file whose line count and exported names had both moved; it is restored to the names it was written with, with a line saying an entry is a dated record and not a description of today. CONTEXT.md said a verb answers with "a list of names". None does -- ``select_tools`` answers with definitions, which the paper spends a paragraph insisting on. Not taken, with reasons. The suggestion to read ``step.phase`` at research_flow/flow.py:1000 instead of reconstructing from ``step.tools_ran``: the two vocabularies coincide on ``after_iteration`` only. gate chain (_phase keys) StepView.phase before_user_inbound user_inbound before_iteration iteration before_execute_tools execute_tools after_iteration after_iteration <- the only member they share terminal_answerless answerless after_send sent That call site wants ``before_execute_tools``, the key ``_phase`` dispatches on, where ``StepView.phase`` says ``execute_tools``, so substituting would route it to a key with no gate behind it. The table is the reviewer's, built while checking the reason rather than taking it. Two phase vocabularies overlapping on exactly one member is a nasty shape: the shared member is the one a reader samples, and it makes the two look like one vocabulary. The salvage narrowing belongs in the PR description's Risk section and has been added there, not here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2081f48 commit 9bfbbad

8 files changed

Lines changed: 104 additions & 22 deletions

File tree

‎CONTEXT.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ _Avoid_: "callback" or "middleware" — neither captures the phase-specific, cha
171171
What a bundled agent judges, written as verbs rather than as phases: `intake`, `select_tools`,
172172
`advise`, `system_addendum`, `review`, `salvage`, `judge`, `outbound`, `archive`. Each is asked
173173
about one step of a Turn, answered against a read-only `StepView` whose `phase` says which of
174-
the six moments is asking, and answered in plain data -- a string, a list of names, a mapping
175-
built by `Intake`, `Accept`, `Resample` or `End` -- never a write into the loop's own state and
174+
the six moments is asking, and answered in plain data -- a string, a narrower list of tool
175+
definitions, a mapping built by `Intake`, `Accept`, `Resample` or `End` -- never a write into the loop's own state and
176176
never an instance of a host class, so a judgement compiled from a dispatch's own source can
177177
answer the same verbs a plugin does. Three invariants hold for every participant whatever its
178178
origin: it is shown facts it cannot write, it answers with data the host interprets, and an

‎pyproject.toml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,19 @@ type = "forbidden"
606606
# provider registry and pricing table reads) moved out to raven/observability
607607
# and RAVEN_HOME resolution moved in to raven/home.py, so an ignore_imports
608608
# entry here would be new debt, not a carry-over.
609+
#
610+
# One type-only edge exists and is deliberate, so it is written here rather
611+
# than left for a reader to discover: contracts/harness.py names
612+
# raven.agent.harness.participants under TYPE_CHECKING, for the Verdict and
613+
# Intake the asking half of the role protocol returns. Those are the shapes the
614+
# shell reads a participant's answer into, and commit-by-commit they were moved
615+
# off the paper on purpose -- a participant answers with a plain mapping, and a
616+
# host class on the paper is exactly what a generated judgement cannot build.
617+
# The price is that the papers name a symbol a papers-only wheel would not
618+
# contain. It costs nothing at import time, which is what this contract is
619+
# about, and lint-imports does not see it because exclude_type_checking_imports
620+
# is on below. If the papers are ever built as their own wheel with type stubs,
621+
# this is the one edge to resolve.
609622
source_modules = ["raven.contracts", "raven.home", "raven.spine", "raven.tracing"]
610623
forbidden_modules = [
611624
"raven.acp", "raven.acp_client", "raven.agent", "raven.auth", "raven.browser",

‎raven/agent/harness/action.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ def ask_judge(
4545
4646
The dispatch's own judgements are one participant among them, asked
4747
after whatever the caller passes, so a product's own rules speak first
48-
and their wording is what the model reads.
48+
and their wording is what the model reads. There is one caller,
49+
``ToolRegistry._verifier_refusals``, and it passes nothing: the order
50+
is real and tested, but nothing arrives in ``participants`` today.
4951
5052
Imported in the call, not for style: ``raven.agent.subagent`` pulls its
5153
manager and every backend on package import, so naming the charter at

‎raven/agent/harness/participants.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,14 @@ def compose_judge(
172172
"""
173173
for participant in participants:
174174
refusals = participant.judge(name, params, prior)
175+
# A bare ``str`` satisfies ``Sequence[str]`` structurally, so neither
176+
# the annotation nor a type checker objects -- and iterating it turns
177+
# one sentence into one refusal per character, which is the opposite of
178+
# the invariant this module opens with. ``charter_code.run_judge``
179+
# coerced it where the composition used to live; moving the
180+
# composition out a layer has to bring the handling with it.
181+
if isinstance(refusals, str):
182+
refusals = [refusals] if refusals.strip() else []
175183
said = [line for line in refusals if isinstance(line, str) and line] if refusals else []
176184
if said:
177185
return said

‎raven/contracts/harness.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
4747

4848
if TYPE_CHECKING:
49+
# The one name a paper takes from outside the kernel, and type-only: the
50+
# shapes the shell reads a participant's answer into live in the harness
51+
# because a participant answers with a plain mapping and a host class on a
52+
# paper is what a generated judgement cannot build. Import-time closure is
53+
# unaffected, which is what "the kernel stands alone" is about; the edge is
54+
# written down beside that contract in pyproject.toml.
4955
from raven.agent.harness.participants import Intake, Verdict
5056
from raven.contracts.assembled import AssembledContext, TokenBudget
5157
from raven.contracts.context import TurnContext
@@ -350,6 +356,14 @@ def ask_judge(
350356
than something this role reads for itself, which is what lets a judgement
351357
generated for one dispatch join the same list.
352358
359+
They are also, today, the only participant in it. The party that asks
360+
this verb is ``ToolRegistry.execute``, which is not the hook chain: a
361+
turn's participants live in its hook context and a registry holds no
362+
handle on them, so ``participants`` arrives empty on every production
363+
call and the composition above describes an order nothing yet fills.
364+
A plugin refuses a call from ``review`` instead. Stated here because a
365+
reader of this protocol has no reason to open the participant paper.
366+
353367
Sentences, not exceptions: a refusal reaches the model as the call's
354368
own result, so its next attempt can be right. Synchronous and cheap by
355369
contract -- this runs before every dispatch and ahead of the permission

‎tests/test_kernel_budget.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,14 @@
140140
first bump gave.
141141
142142
3,200 -> 3,410 (2026-09-18), and this is that review. The change is a whole
143-
paper: contracts/participant.py, 191 lines, the nine verbs a sub-agent
144-
implements instead of the six hook phases (``AgentParticipant``, its ``StepView``
143+
paper: contracts/agent_conduct.py, 191 lines, the nine verbs a sub-agent
144+
implements instead of the six hook phases (``AgentConduct``, its ``StepView``
145145
of fourteen read-only fields, the ``Intake`` and ``Verdict`` it answers with,
146-
the per-turn ``ParticipantFactory``, and a diagnostic trail so a gate's one-line
147-
findings still reach the loop's notes). It sits beside ``loop_hooks`` rather
146+
the per-turn ``ConductFactory``, and a diagnostic trail so a gate's one-line
147+
findings still reach the loop's notes). Left in the names and the count it was
148+
written with: an entry here is a dated record of one bump, not a description of
149+
the file today, and the paper has since been renamed to contracts/participant.py
150+
and grown past 191 lines -- both under their own entries below. It sits beside ``loop_hooks`` rather
148151
than replacing it: the phases remain the loop's timing contract, this is the
149152
judgement contract, and an adapter seats one in the other. Surveyed before it
150153
was written -- 42 real hook implementations across five plugins read fourteen
@@ -221,6 +224,21 @@
221224
222225
Measured at 3,528.
223226
227+
228+
And once more, 3,550 -> 3,570 (2026-09-18), for review. Prose again, and again
229+
about what the papers do not do. The ``judge`` disclosure had landed in two of
230+
its four homes; ``ActionModule.ask_judge`` on this paper was a third, and it is
231+
the one a reader of the role protocol meets first, phrased without naming
232+
anything the earlier fix touched -- which is why grepping the claim rather than
233+
the symbol is what found it. The other addition is the type-only edge from this
234+
paper to ``raven.agent.harness.participants``: the first the kernel has, taken
235+
knowingly when ``Verdict`` and ``Intake`` moved off the paper so a generated
236+
judgement could build what a participant answers with. It costs nothing at
237+
import time and lint-imports does not see it, so it is written down here and
238+
beside the contract it bends rather than left for the next reader to find.
239+
240+
Measured at 3,542.
241+
224242
"""
225243

226244
from __future__ import annotations
@@ -231,7 +249,7 @@
231249
from pathlib import Path
232250

233251
LINE_CEILING = 2_000
234-
CONTRACTS_LINE_CEILING = 3_550
252+
CONTRACTS_LINE_CEILING = 3_570
235253
THIRD_PARTY_ALLOWED = frozenset({"loguru"})
236254
DEBT_MARKER = re.compile(r"\b(TODO|FIXME|HACK)\b")
237255

‎tests/test_participant_hook.py‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -315,30 +315,34 @@ async def archive(self, step, reply):
315315
], f"a phase is mislabelled: {seen}"
316316

317317

318-
def test_a_seated_participant_is_not_asked_to_judge_a_tool_call():
319-
"""``judge`` has a role but no seat, and the contract says so. The party
320-
that asks it is the tool registry, which is not the hook chain and holds no
321-
handle on this turn's participants -- so today only a dispatch's own rules
322-
answer it. Pinned rather than left implicit: whoever wires the seat through
323-
should have to come here and say the prose has changed."""
318+
def test_the_registry_asks_judge_with_no_participants_at_all():
319+
"""``judge`` has a role but no seat, and three docstrings now say so. What
320+
makes those sentences true is this: the one party that asks the verb is the
321+
tool registry, and it passes nothing, so the list the role composes holds
322+
only the dispatch's own rules.
323+
324+
Asserted on the argument rather than on the refusal that comes back. A test
325+
that only checked "the Charter still refuses" would stay green after someone
326+
plumbed the seat's participants through -- which is the change these
327+
docstrings would silently outlive. Here that change makes ``handed``
328+
non-empty and turns this red, which is the point."""
324329
from raven.agent.harness import DefaultAction
325330
from raven.agent.subagent.charter import Charter, CheckRule, charter_scope
326331
from raven.agent.tools.registry import ToolRegistry
327332

328-
asked: list[str] = []
333+
handed: list[tuple] = []
329334

330-
class ProductRules(AgentParticipant):
331-
def judge(self, name, params, prior):
332-
asked.append(name)
333-
return ["the product refuses this one"]
335+
class Spy(DefaultAction):
336+
def ask_judge(self, name, params, prior, participants=()):
337+
handed.append(tuple(participants))
338+
return super().ask_judge(name, params, prior, participants)
334339

335-
ParticipantHook("product", ProductRules) # seated is not enough; nothing carries it across
336-
registry = ToolRegistry(verifier_provider=lambda: DefaultAction())
340+
registry = ToolRegistry(verifier_provider=Spy)
337341
with charter_scope(Charter(checks=(CheckRule(tool="write_file", path_prefix="out/"),))):
338342
refusals = registry._verifier_refusals("write_file", {"path": "/etc/passwd"})
339343

344+
assert handed == [()], f"the registry handed the role participants: {handed}"
340345
assert refusals, "the dispatch's own rules must still be asked"
341-
assert asked == [], "a seated participant is not on the registry's list -- see AgentParticipant.judge"
342346

343347

344348
@pytest.mark.asyncio

‎tests/test_subagent_charter.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,29 @@ def judge(self, name, params, prior):
448448
]
449449

450450

451+
def test_one_sentence_of_refusal_stays_one_sentence() -> None:
452+
"""A bare ``str`` satisfies ``Sequence[str]`` structurally, so a participant
453+
that refuses with one sentence passes every annotation and every type check
454+
-- and iterating it yields one refusal per character. Eighteen refusals for
455+
a malformed answer is the opposite of "a malformed answer is silence", and
456+
this seam exists to carry judgements that are generated rather than written,
457+
where a bare string is a plausible thing to answer."""
458+
from raven.agent.harness.participants import compose_judge
459+
460+
class Sentence:
461+
def judge(self, name, params, prior):
462+
return "write under ./out/"
463+
464+
class Blank:
465+
def judge(self, name, params, prior):
466+
return " "
467+
468+
assert compose_judge("write_file", {}, [], [Sentence()]) == ["write under ./out/"]
469+
assert compose_judge("write_file", {}, [], [Blank(), Sentence()]) == ["write under ./out/"], (
470+
"whitespace is not a refusal, and must not stop the next participant"
471+
)
472+
473+
451474
def test_a_participant_that_says_nothing_lets_the_next_one_speak() -> None:
452475
"""A veto stops at the first refusal, not at the first participant."""
453476
from raven.agent.harness.action import DefaultAction

0 commit comments

Comments
 (0)