feat: Corpus.default_agent + C8 consumer agent — make a pack's agent wiring installable - #2274
Conversation
A corpus could not have a default agent, and two separate mechanisms had to
change before one could exist:
1. SELECTION. `_resolve_agent_config` priority 3 resolved a hardcoded GLOBAL
slug, `default-corpus-agent`. A CORPUS-scoped AgentConfiguration was
never chosen, however it was configured.
2. APPLICATION. `system_instructions` and `available_tools` were threaded
into the factory only `if self.agent_config and self.agent_config_id` --
i.e. only on the explicit `?agent_id=` path. So even a config that WAS
resolved by fallback had its fields silently discarded.
Both were written when REPLACE was the only instructions mode, and gate 2's
stated reason is specific to it: a generic default's instructions would
clobber the richer per-corpus persona. EXTEND (PR #2262) appends instead of
replacing, so there is nothing to clobber -- and excluding EXTEND configs only
produces a default that resolves and is then ignored, which is the harder
failure to diagnose. REPLACE keeps its original fallback-path behaviour, and
the existing test asserting that is unchanged and still passes.
`Corpus.default_agent` is an explicit FK rather than a query over
CORPUS-scoped agents because corpora already hold scoped agents for unrelated
purposes (the inline moderator in corpus_mutations), and picking positionally
among them would silently hand corpus chat to whichever sorted first.
`Corpus.save` refuses a pointer at an agent scoped to a DIFFERENT corpus,
which would serve that corpus's private instructions to this one's users;
checked in save() rather than as a DB constraint because the condition spans
two tables.
Also adds C8 to install_domain_pack: a domain pack may declare a
`consumer_agent` (instructions_file, tools, mode) and `--consumer-corpus <pk>`
binds it. The split is deliberate -- the pack owns the text because the group
slug it must name is the pack's own invention, and the operator owns the
binding because which corpus consumes a domain is unknowable at authoring
time. mode is required to be EXTEND: REPLACE would overwrite the consuming
corpus's persona with pack-supplied text, which is exactly the coupling
DOMAIN_PACKS.md prohibits. Declaring without binding reports it (C5) rather
than passing silently.
mypy.ini gains a django-manager-missing disable for agents.models, the same
PermissionedTreeQuerySet limitation already disabled for users/annotations/
documents; the comment records why CorpusGroup's sibling relation does not
trip it.
Tests were written but NOT run locally: this machine's postgres credentials
are mismatched (django authenticates as `doadmin`, the running container was
initialised with `oc_user`) and running test.yml risks evicting a live stack
via the shared compose project name. Lint and mypy pass against a
freshly-resolved env; pytest verification is CI's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review: Corpus.default_agent + C8 consumer agentSolid piece of work — the two-gate root-cause analysis (selection and application both silently dropping a corpus-scoped default) is exactly right, the 1.
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
5 failed / 11196 passed on the first run. Both causes were mine. 1. REGRESSION. Relaxing the fallback-path gate made `agent_config.system_instructions_mode` an unconditional attribute read on a path that previously never touched the field. Two existing tests build `agent_config` as a SimpleNamespace carrying only `preferred_llm` and `system_instructions`, so the read raised AttributeError where the code had simply skipped before. Now `getattr(..., None)`: REPLACE is the documented default, so "absent" and "REPLACE" must behave identically, and a partially-populated stand-in must not become an exception. 2. WRONG TEST BASE. The three resolution tests used `pytest.mark.asyncio` on a Django `TestCase` and failed with "connection already closed" — the consumer resolves the corpus on its own connection, so the rows have to be committed, which a TestCase's wrapping transaction prevents. Moved to `tests/websocket/test_unified_agent_consumer.py` under `WebsocketFixtureBaseTestCase` (TransactionTestCase-backed), the base the repo's other async consumer tests already use — which also supplies the `default-corpus-agent` row they assert the fallback to. The sync guard tests passed and stay where they are, with a pointer to where the other half went. Neither was reachable from lint or mypy, which is the whole reason the PR said pytest verification was CI's rather than claiming coverage I had not observed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review Solid, well-scoped feature. The two-gate diagnosis in the PR description (selection vs. application) is correct, and the EXTEND-only relaxation is the right fix: it does not touch REPLACE behavior on the fallback path, which is exactly the guard the original comment was protecting. The Corpus.save() cross-corpus leak guard, the permission ordering (corpus READ is checked in _validate_resource_permissions before _resolve_agent_config runs, so the new _corpus_default_agent() query needs no extra visibility check of its own), and the install_domain_pack C8 split (pack owns text, operator owns binding, each half reports its own diagnostic) all check out. The consumer-agent wiring mirrors the existing orchestrator wiring block almost exactly (slug validation, search_across_corpora/group-slug check, GRANTABLE_TOOLS gate) -- good, since it is a variant of an already-reviewed pattern rather than a new one. Bug: dead code in _corpus_default_agent In config/websocket/consumers/unified_agent_conversation.py, at the end of the new _corpus_default_agent method: The second "return None" is unreachable. It looks like a leftover from moving the trailing return out of _resolve_agent_config when this helper was split out. Harmless at runtime, but it is dead code (CLAUDE.md flags this explicitly) and reads as a mistake to the next person touching the file. Worth a quick follow-up deletion. Minor / non-blocking observations:
Test coverage: The 11 new tests cover the right matrix: the FK guard (GLOBAL ok / same-corpus ok / other-corpus refused), the resolution preference (unset falls back / corpus default wins / inactive falls back), and the 8 C8 install-command paths (bind, unbound-reported, corpus-without-spec, REPLACE-refused, missing-slug-reference refused, ungrantable-tool refused, idempotent, missing-file refused). The second commit moving the resolution tests to a TransactionTestCase-backed base (because the consumer resolves the corpus on its own connection) is the correct diagnosis for a TestCase-wrapped-transaction visibility problem, not a workaround. One gap: no test exercises Corpus.save()s "agent is None" branch (a default_agent_id pointing at a since-deleted row). Low priority given on_delete=SET_NULL makes it hard to construct organically, but it is the one branch in the new guard without direct coverage. Overall: looks ready to merge modulo deleting the dead return None. |
Patch coverage came in at 87.65% -- 10 lines, and all ten were error paths I added without exercising. Every one is a guard, and a guard with no test is indistinguishable from a guard that never fires, so they are worth covering rather than waiving: install_domain_pack (8): consumer_agent that is not a mapping, a spec with no instructions_file, an instructions file that is present but empty, a preferred_llm that must reach the AgentConfiguration, and a --consumer-corpus naming a pk that does not exist. corpuses/models (1): default_agent_id pointing at no row. Assignable without loading the target, so a stale pk from a fixture or a fork would otherwise persist and surface only when chat tried to resolve it. unified_agent_conversation (1): neither document nor corpus in scope resolves to None -- the caller must not receive a default it never asked for. codecov/patch is not a required check on main, so none of this blocked the merge. Every recently merged PR passes it, though, which makes a red one a signal rather than noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewSolid, well-reasoned change — the writeup on the two independent gates (selection vs. application) is accurate and the test coverage for the guard/resolution/install-contract paths is thorough. Two findings from reading the diff closely, plus a couple of minor nits. 1.
|
…rred_llm validation, dead code - install_domain_pack: extract the file-decidable half of the C8 consumer_agent contract (mode, instructions_file, tools, search_across_corpora/group-slug, preferred_llm) into _consumer_agent_violations, shared by _preflight and _wire_consumer_agent. Previously --check reported "0 violations" for a consumer_agent that would hard-fail the moment --consumer-corpus was supplied on a real install; _report_plan now also names consumer_agent instead of staying silent about it. - consumer_agent.preferred_llm now gets the same C3-style validate_model_spec check the orchestrator's preferred_llm already had, so an unusable model spec fails cleanly instead of surfacing AgentConfiguration.save()'s bare ValidationError. - Reordered the corpus-pk lookup in _wire_consumer_agent to run after the unmet check, so a bad --consumer-corpus no longer masks an already-known manifest violation. - Added test_wire_consumer_agent_still_refuses_a_bad_mode_if_preflight_is_bypassed, mirroring the existing C2 cap bypass test, since _wire_consumer_agent's copy of these checks is now unreachable on every real-install test path once _preflight catches them first. - config/websocket/consumers/unified_agent_conversation.py: remove unreachable `return None` after _corpus_default_agent's real return.
ReviewOverall this is a careful, well-documented change: the two-gate diagnosis (selection vs. application) is correct, the Policy issue (please fix before merge)CLAUDE.md's Baseline Commit Rule #3 is violated: "Never credit Claude or Claude Code in commit messages, PR messages, comments, or any other artifacts. This includes Co-Authored-By lines, 'Generated by Claude', etc."
Please strip the attribution line from the PR body and, if feasible, the co-author trailers from the commit messages (or squash/reword on merge) to comply with the repo's own rule. Correctness
Minor observations (non-blocking)
Test coverageGood breadth — guard tests (same-corpus/other-corpus/nonexistent), resolution-priority tests correctly moved to a |
The problem
A domain pack can install a corpus group and a GLOBAL orchestrator. It cannot install the thing that actually answers a user's question: an agent scoped to the corpus holding their documents, carrying both the corpus persona and reach across the group.
That configuration was reachable only by running a script by hand against one database. Anyone installing the pack got the orchestrator-only shape.
Why it wasn't just a missing field
Two independent mechanisms had to change. Neither is obvious from the outside, and the second is the one nobody had noticed:
default-corpus-agent, so a CORPUS-scoped config is never chosen_resolve_agent_configif self.agent_config and self.agent_config_id:— fields apply only on the explicit?agent_id=path_initialize_agentGate 2 means that even a config resolved by fallback had its
system_instructionsandavailable_toolssilently discarded. Fixing selection alone would have produced a default that resolves and then does nothing.Both were written when REPLACE was the only mode, and gate 2's own comment gives the reason: a generic default's instructions "would clobber the richer per-corpus persona." That is true of REPLACE and false of EXTEND (#2262), which appends. So the relaxation is narrow:
REPLACE keeps its original fallback-path behaviour. The existing test asserting that (
test_default_agent_keeps_factory_prompt_and_tools) is unchanged and still passes — I only made its mode explicit and added the EXTEND counterpart.Design notes worth reviewing
Why an explicit FK, not "find the corpus-scoped agent". Corpora already carry scoped agents for unrelated purposes —
corpus_mutations.pycreates an inline moderator. Choosing positionally among them would silently hand corpus chat to whichever sorted first.Why
Corpus.savevalidates.default_agentis the fallback, so a pointer at an agent scoped to a different corpus would serve that corpus's private instructions to this one's users. Checked insave()rather than as a DB constraint because the condition spans two tables.Why EXTEND is required for
consumer_agent, not merely preferred. REPLACE would overwrite the consuming corpus's persona with text shipped by a third party — precisely the couplingDOMAIN_PACKS.mdforbids. Requiring EXTEND is what makes the feature compatible with that prohibition rather than an exception to it.Why the split. The pack authors the instructions because the group slug they must name is the pack's own invention. The operator names the corpus because which corpus consumes a domain is not knowable when the pack is written. Supplying one without the other is a mistake in both directions, and each gets its own diagnostic — declaring
consumer_agentwithout--consumer-corpusreports that it was not applied (C5) rather than passing silently.Companion PR
The
domain.yamlschema, validator and contract text land in the registry: Open-Source-Legal/authority-packs#8. This PR is the platform half; that one is the schema half. Neither is useful alone.Lint and mypy pass against a freshly-resolved pre-commit env (
pre-commit cleanfirst, since a cached env is not the env CI builds).I did not run pytest locally. This machine's postgres credentials are mismatched — django authenticates as
doadminwhile the running container was initialised withoc_user— and runningtest.ymlrisks evicting a live 46-hour stack through the shared compose project name. I judged that not worth the risk on someone else's machine. CI is the verification for the 11 new tests, and I'd rather say so than imply coverage I didn't observe.New tests:
test_corpus_default_agent.py— the guard (3 cases: GLOBAL ok, same-corpus ok, other-corpus refused) and the preference (3 cases: unset falls back, corpus default wins, inactive falls back)test_unified_agent_consumer.py— EXTEND applies on the fallback path asextra_system_context(notsystem_prompt, which would consume the "caller supplied nothing" signal and discard the persona — the Corpus/document agent instructions are silently discarded: temporal grounding consumes thesystem_prompt is Nonesignal #2247 bug)test_domain_pack_install.py— 8 C8 cases: binds, unbound-is-reported, corpus-without-spec errors, REPLACE refused, missing group slug refused, ungrantable tool refused, idempotent, missing file refused🤖 Generated with Claude Code