Skip to content

d4d-agent workflow: respond only to an explicit request with a dataset argument, never to a handle quoted in prose or code (#4108) - #4390

Open
realmarcin wants to merge 6 commits into
mainfrom
fix/4108-assistant-explicit-request-only
Open

realmarcin wants to merge 6 commits into
mainfrom
fix/4108-assistant-explicit-request-only

Conversation

@realmarcin

@realmarcin realmarcin commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4108

.github/workflows/d4d-agent.yml started its check-mention job on any issue, pull request or comment body that contained the assistant's handle (contains() does not see Markdown). Its detect step then took the rest of the line after the handle and a blank as the request. For an author in .github/ai-controllers.json, a body that merely quoted the handle before an input directory name could start a billed d4d api run and open a PR. Without a dataset name, it posted "I could not tell which dataset to generate". Run 36902377148 on #4093 reached the detect step; only the backtick after the quoted handle kept the regex from reading a request.

What changes

New src/github/assistant_request.py makes the decision (standard library only, Python 3.9 or later). A request is one line that:

  • starts with the assistant's handle at column 0 (any ASCII letter case; a longer login is a different account);
  • continues with spaces or tabs and the exact name of one directory under data/sheets_d4dassistant/inputs/, with nothing after it;
  • stands alone: a blank line, or the start or end of the text, before it and after it;
  • lies outside fenced code blocks and outside HTML blocks that span blank lines (comments, <pre>, <script>, <style>, <textarea>, processing instructions, declarations, CDATA);
  • comes after no raw HTML that is left open. A line that may be raw HTML must close each comment and tag it opens. No line outside a fence may hold a start tag of <textarea>, <script>, <svg> or a similar element, a processing instruction, CDATA or --!>.

A quoted line, a list item, an indented code block or a table row either does not start with the handle or does not stand alone. A text whose request lines name different datasets holds no request. Anything else logs no request, with the reason for each line that starts with the handle (where requests conflict, one note names every line that asks), and nothing runs.

d4d-agent.yml:

  • The job-level contains() check stays, documented as a cheap first gate only.
  • The detect step now only authorizes the author and writes the text to $RUNNER_TEMP.
  • A new Read the request step runs the script on the runner's own python3, only for allowed authors, and sets qualified-mention and dataset.
  • The respond job takes --project from that dataset output and re-checks it against its own checkout, instead of searching the free text for a dataset name.
  • The "could not tell which dataset" branch is removed, since nothing reaches it now. The no-bundle report is kept.
  • Unchanged: allowed authors, triggers, the d4d api run command, the prompts, which file the run reads, and how manual dispatch picks a comment.

.github/D4D_ASSISTANT_README.md: the request section describes the line format. It says which file a run reads (the first .txt or .md under the directory, paths sorted), where the directory must exist for each kind of event, and which directory names a request can carry. The three free-text example requests are removed, since none of them is a request now.

Decisions the brief left open

  • What counts as a declared project: a subdirectory of data/sheets_d4dassistant/inputs/. That is the set the old resolve step already matched; d4d api run --bundle accepts any name. Names must match [A-Za-z0-9][A-Za-z0-9_.-]* because they reach shell commands; a subdirectory named otherwise is logged as one a request cannot name. Matching is exact; the old grep ignored case.
  • Nothing may follow the dataset name, on its line or on the next, so prose such as " CHORUS runs failed" is not a request.
  • How "outside code spans" is enforced: by the stand-alone rule, not an inline parser. No code span, inline comment or quotation crosses a blank line. After one, a list item continues only on indented lines. A line with a blank line after it cannot be a heading's text or a table's header. The cost: a request line directly before or after other text, with no blank line between, is refused and logged.
  • Fails closed where a line reader cannot place the end of a fence or HTML block without parsing lists or HTML: a fence or HTML block opened on a list marker, content to the left of an indented opener, an indented fence closed past three spaces, and a fence or HTML block opened in what may be an HTML block. It also fails closed after raw HTML that may be left open, as above. In those cases no later line is read as a request, and a later line that would ask for another dataset still cancels a request before it.
  • No third-party Markdown parser: it would add a network install to a gate that runs on every event that passes the pre-filter. The raw-HTML check is a small reader of the HTML tokenizer's comment and tag rules.
  • An unknown dataset gets no reply comment. It is logged as "no request" and nothing runs. The README says so.

Evidence

Tests

  • tests/test_assistant_request.py (25 tests) covers:
    • the brief's fixtures and their near misses;
    • every HTML block kind, the stand-alone rule, extra words, indentation, two datasets, Unicode line separators, lines of other white space, non-ASCII case folds of the handle, and the fail-closed cases;
    • raw HTML left open, and raw HTML that closes;
    • the workflow wiring;
    • the step's own command, run as written with python -S.
  • Mutation testing:
  • With tests/test_generation_specificity_skill.py and tests/test_audit_recall_leak.py: 215 passed. The parser and command-line tests also pass under Python 3.9.6.

Follow-ups filed

Review round 1

Twelve findings were filed as #4422-#4433. All twelve are fixed here.

Behavior change in round 1: a request line followed directly by other text was read before; it is now refused and logged.

Proposed follow-ups, not filed from this PR:

  • having the run read every document in the directory rather than one file;
  • replying when an allowed author's request names a directory the run cannot see;
  • content hidden by what an element does rather than by where its markup ends (<template>, a hidden attribute, <select>, the fallback content of <video> or <canvas>). This needs GitHub's sanitizer checked first.

🤖 Generated with Claude Code

realmarcin and others added 2 commits October 5, 2026 03:37
…put directory (#4108)

The check-mention job started on any issue, PR or comment body that
contained the assistant's handle, and its detect step took the rest of
the line after the handle as the request. For an allowed author, a body
that merely quoted the handle before an input directory name could
start a billed `d4d api run` and open a PR. On #4093 (run 36902377148)
only the backtick after the quoted handle prevented a request.

src/github/assistant_request.py now makes the decision. A request is
one line that starts a paragraph with the handle at column 0, followed
by the exact name of one directory under data/sheets_d4dassistant/inputs/
and nothing else, outside fenced code and multi-line HTML blocks.
Quoted lines, list items and indented code never qualify, and a text
naming two datasets holds none. Where the line reader cannot place the
end of a fence or HTML block without parsing lists or HTML, it reads no
later line as a request. Anything else logs "no request" and runs
nothing.

The workflow keeps contains() as a cheap first gate. The detect step
now only authorizes the author and hands the text to the new "Read the
request" step, which sets qualified-mention and dataset. The respond
job takes --project from that output instead of grepping free text.
The "could not tell which dataset" branch is gone, since nothing
reaches it now. Allowed authors, the billed command and the prompts
are unchanged.

The README's request section describes the line format, and its
free-text examples are removed. Tests cover the #4093 body verbatim,
the handle in code spans, fences, quotes, mid-sentence and HTML
comments, unknown datasets, and the workflow step run as written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ctly (#4108)

Docstrings only. A list item does continue past a blank line, on
indented lines, so the paragraph rule rests on that plus column 0. The
log head is plain "no request" when reasons follow it, and a Decision's
notes also carry the conflicting-datasets reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Oct 5, 2026
realmarcin and others added 4 commits October 5, 2026 09:17
…nflict past lost track cancel (#4422, #4423)

Review round 1 of PR #4390.

Raw HTML (#4422). A browser reads an HTML comment, a tag with its quoted
attribute values, and the content of elements such as <textarea> to their
own end, not to the end of the Markdown block that holds them, so one left
open hides every later line. A line that may be raw HTML (in or opening an
HTML block, quoted, on a list marker, or indented) must now close each
comment and tag it opens, read by the HTML tokenizer's tag and comment
states; an HTML block of kinds 1-5 must do so by its last line. A start tag
of a raw-text, RCDATA or PLAINTEXT element or of <svg>/<math>, a processing
instruction, CDATA and "--!>" are refused on any line outside a fence:
CommonMark passes them through even inside a paragraph, and the tokenizer
ends the last three before CommonMark does. Otherwise no later line is read.

Conflicts (#4423). A line past the point where the reader loses track is
still not a request, but one that would ask for another dataset now cancels
a request before it, and the note says it cannot be ruled out.

A request line now stands alone: a blank line, or the start or end of the
text, after it as well as before it. With text under it Markdown makes the
line a heading, a table header or the start of a sentence (#4429, #4430).
The two notes state that rule instead of claiming the line "does not start
a paragraph", which was false after a heading, a fence or a comment (#4430).
The docstring credits the stand-alone rule, not the absence of the handle,
for keeping quotes, list items and table rows out (#4429).

The conflict note names every line that asks, a repeated dataset too
(#4431). A directory a request cannot name because of its name is reported
as such, not as "not an input directory" (#4433); input_directories()
replaces declared_datasets(). Comments say why the blank-line and closing
fence checks strip only spaces (#4427) and why the handle is ASCII-only
(#4428).

Tests: each HTML block kind the docstring lists, with a blank line after the
request line so a broken opener reads a request (#4425); the closing-fence
and tilde info-string rules (#4426); lines of other white space (#4427); the
non-ASCII case folds of the handle (#4428); and the cases above.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t be, and the stand-alone rule (#4424, #4433)

Review round 1 of PR #4390.

The request section said the datasheet is generated "from the documents in"
the input directory. The resolve step passes one file as the bundle: the
first .txt or .md under the directory, paths sorted. The README now says so,
says that no other file is read, and says to put several documents into
one file (#4424). A comment at the resolve step points back to the README,
by name and not by path, so the specificity audit does not take the README
for a file the workflow reads.

It now also says where the workflow looks for the directory: the default
branch for a request in an issue or in any conversation comment, the pull
request's merge commit for one in a pull request description or a review
comment, the dispatched branch for a manual run. And it states the rule a
directory name must follow, and that a request naming a directory that is
not there, or whose name breaks the rule, logs "no request" and gets no
reply (#4433).

The line format follows the parser: a request line stands alone, and one
after a block whose end the reader cannot place, or after raw HTML left
open, is not read. The workflow's comment at "Read the request" says the
same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and an inline "--!>" (#4422)

Review round 1 of PR #4390. Three raw-HTML cases the tests did not reach,
each checked to hide the request line when rendered (markdown-it in its
commonmark and gfm-like presets, parsed by html5lib):

- a bogus comment ends at its first ">", so a quote or comment after it is
  read as markup: without that branch a tag swallowing the "<!--" reads
  as closed;
- a comment in a block quote inside a list item is raw only through the
  list-marker condition;
- a comment that "--!>" ends early, in a paragraph, is caught only by the
  substring rule, since the line is not one that may be raw HTML.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…>" (#4422)

OPAQUE refuses both before the tokenizer runs, so a mutant that drops
them from _comment_end or the bogus-comment branch survives the tests.
The docstring now says the redundancy is deliberate: the tokenizer keeps
following its own rules, so narrowing OPAQUE cannot make it read past them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The GitHub assistant workflow fires on any issue or PR body that quotes its handle

1 participant