Add group-level custom AI instructions to the embedded AI context block
What does this MR do and why?
Adds a group-level custom AI instructions setting that is inlined into the embedded "GitLab AI Context" block. The instructions cascade down a group's ancestor chain to its subgroups and their projects.
This builds on the already-merged base work that embeds the AI context block in HTML responses. That base block gives an agent the automatic, zero-config context (project path, repository, key files, tooling). This MR adds the one thing GitLab can't infer: the human intent behind a namespace — the workflow, conventions, and guard-rails a group owner wants every agent to follow.
When set, the block gains a labelled section:
Custom instructions:
[Group: gitlab-community] Use the community fork workflow: branch in the fork, open the MR against canonical.
[Group: gitlab-community/backend] Run `bundle exec rubocop` before pushing.Why group level (and only group level)
The base AI-context block already surfaces links to README.md, CONTRIBUTING.md, AGENTS.md. So a fair question is: why add a settings field at all, when everything you'd type here could just go in those files?
Four reasons, and the first is the one that actually forces the design:
-
At the group level there is no repository — and therefore no file to point at.
AGENTS.md/CONTRIBUTING.mdonly exist inside a repo. A group that spans dozens or hundreds of projects has nowhere to hang cross-cutting guidance ("all repos here use the community-fork workflow", "target the canonical project, not your fork"). This setting is the only place that guidance can live. This is the primary use case. -
Deduplication. Guidance that applies to every project in a group is written once at the group level instead of being copy-pasted into N
AGENTS.mdfiles that then drift out of sync. Change it in one place, every project's agent context updates. -
Removes a hop. The instructions are inlined directly into the page the agent is already reading. There's no second fetch of a raw file, and no dependency on the agent deciding to follow a link. Content that's already in the context window can't be skipped.
-
Some guidance shouldn't be in the repository. A settings field is not cloned, not forked, and not part of the public source tree. Internal triage conventions, "route questions about X to team Y", or "don't touch the vendored directory" are useful directives for an agent but are not things you necessarily want committed to a public
AGENTS.mdand mirrored into every fork. This is a capability a file genuinely cannot provide.
We deliberately scope this to the group level and leave project and instance out:
- Project already has the natural home for AI guidance — the repo's own
AGENTS.md/CONTRIBUTING.md, which are human-and-AI facing and versioned with the code. A project-level settings field would just duplicate that with a worse editing story. (Projects still benefit here: they inherit their group's cascaded instructions in the block.) - Instance is intentionally out of scope for now: instance-wide AI directives are a much broader policy/blast-radius decision and can be revisited separately if there's demand.
So files and this setting are complementary, not competing: repo-level, human-and-AI-facing docs stay in CONTRIBUTING.md; short, cross-project, AI-facing directives live at the group level where no file can reach.
Scope
- Group: Group → Settings → General → AI context (
NamespaceSetting#ai_custom_instructions) - Cascades down the full ancestor chain to subgroups and their projects.
- Free-text field, max 2000 characters.
Design decisions
Cascade, not override
Group levels are concatenated (each ancestor group + the group itself), ordered most-general first, most-specific last, so an agent reads the most specific instruction last. Blank levels are skipped.
We deliberately do not use GitLab's built-in cascading_attr, which is first-non-nil-wins (an override model). Override would mean a subgroup setting hides its parent-group guidance — exactly the wrong behaviour here: a top-level "use the community fork" rule and a subgroup "run rubocop" rule should stack, not mask each other. Concatenation is why Gitlab::Ai::CustomInstructionsResolver exists as a custom resolver rather than reusing cascading_attr.
Each entry is prefixed with its origin group's full path ([Group: full/path]) so the agent — and anyone reading the source — can see where each directive comes from.
This is currently a reasoned choice, not a measured one. See Evidence for what is and isn't proven, and what we plan to measure.
This is an AI-facing surface, not a docs replacement
The block is rendered gl-hidden — invisible to humans on the page. That has two consequences worth stating plainly:
- It does not make
CONTRIBUTING.md/AGENTS.mdredundant. Those files are human-facing, versioned, reviewable in an MR, and diffable. This setting is none of those: it is a free-text field changed outside code review with no history. It is deliberately the smaller surface — short cross-cutting directives, not a knowledge base. - Audience is a real trade-off. Group instructions are seen by every agent that fetches any page in that namespace — both an external contributor's tool and an internal team member's tool. Onboarding/forking guidance placed at the group level is therefore also read by internal-team agents. That's acceptable for the target use cases (the guidance is generally applicable), but it's the reason this should stay short and generally-applicable rather than becoming a dumping ground: level your instructions to their real audience.
Evidence
The DevRel AI contribution evals measure whether a specific piece of guidance actually reaches an agent's final answer (5 runs per round, OpenCode, LLM-as-judge).
What is proven: a rule that exists only in the inlined custom-instructions section reliably lands. The follows_custom_instruction checkpoint — a custom MR-title-prefix rule whose text appears nowhere else in the fixtures — scores:
| Round | follows_custom_instruction |
|---|---|
baseline (no AI-context block) |
0/5 |
ai_context_current (block + real CONTRIBUTING/README/AGENTS) |
0/5 |
ai_context_enriched (block + enriched CONTRIBUTING.md) |
0/5 |
ai_context_custom (block + inlined custom instructions) |
5/5 |
Linking to a file is not sufficient — the rule is 0/5 even when CONTRIBUTING.md is enriched and linked. Inlining it is what makes it land. Overall pass rate moves 82% → 92% (detailed prompt) and 11% → 76% (simple prompt), but the per-checkpoint result above is the meaningful one: the other checkpoints are partly satisfied by guidance the agent would produce anyway, so "overall" understates and overstates in different rounds.
What is not yet proven. The existing round validates the mechanism (inlined custom instructions reach the agent), using a single project-level entry. It does not yet cover:
- the group-level
[Group: <full_path>]format this MR ships, - multi-level cascade (two or more ancestor groups concatenated),
- whether ordering alone communicates precedence when levels conflict.
Follow-up eval work is planned to close exactly that gap, including a cascade-vs-override comparison so the design decision above is backed by a measurement rather than an argument.
How to set up and validate locally
- Set instructions on a group (and optionally a parent group) at Group → Settings → General → AI context.
- Open a work item in that group (or a project inside it) and inspect the embedded block (
[data-testid="ai-context"]) for aCustom instructions:section, ordered outermost group → nearest group. - Clear the field on every level and confirm the
Custom instructions:section disappears entirely.
DB queries and execution plans
Plans below are from a local GDK, so the costs are not meaningful (tables are near-empty) — they establish index selection and access-method shape.
Q1 — ancestor traversal (column list elided for readability):
SELECT namespaces.* FROM (
SELECT namespaces.*, ABS(5 - array_length(traversal_ids, 1)) AS depth
FROM namespaces
WHERE namespaces.type = 'Group'
AND namespaces.id IN (119, 120, 121, 122, 123)
) namespaces
WHERE namespaces.type = 'Group'
ORDER BY depth DESCSort (cost=2.18..2.18 rows=1 width=414)
Sort Key: (abs((5 - array_length(namespaces.traversal_ids, 1)))) DESC
-> Index Scan using index_namespaces_on_type_and_id on namespaces (cost=0.14..2.17 rows=1 width=414)
Index Cond: (((type)::text = 'Group'::text) AND (id = ANY ('{119,120,121,122,123}'::bigint[])))Q2 — namespace_settings preload (with_namespace_settings):
SELECT namespace_settings.* FROM namespace_settings
WHERE namespace_settings.namespace_id IN (119, 120, 121, 122, 123)Index Scan using namespace_settings_pkey on namespace_settings (cost=0.14..3.38 rows=5 width=414)
Index Cond: (namespace_id = ANY ('{119,120,121,122,123}'::bigint[]))Q3 — routes preload (include_route):
SELECT routes.* FROM routes
WHERE routes.source_type = 'Namespace' AND routes.source_id IN (119, 120, 121, 122, 123)Index Scan using index_routes_on_source_type_and_source_id on routes (cost=0.14..2.85 rows=4 width=81)
Index Cond: (((source_type)::text = 'Namespace'::text) AND (source_id = ANY ('{119,120,121,122,123}'::bigint[])))| Query | Index used |
|---|---|
| ancestor traversal | index_namespaces_on_type_and_id |
namespace_settings |
namespace_settings_pkey (PK on namespace_id) |
routes |
index_routes_on_source_type_and_source_id (unique) |
References
- Issue: #601517
- Superseded MR: !241808 (closed)
- AI contribution evals: https://gitlab.com/gitlab-org/developer-relations/contributor-success/ai-contribution-evals

