fix(context): honor directive order and singular rejections

This commit is contained in:
Yann Roberto
2026-10-01 15:03:34 +08:00
parent f302863d58
commit 2d2712b899
3 changed files with 143 additions and 31 deletions
+49 -29
View File
@@ -29,7 +29,8 @@ const FALLBACK_MARGIN = 1.1;
// v4: an explicit empty proposal (decline) is honored; the tier-2 fallback no
// longer overrides declines at the launch/selection call sites.
// v5: indirect name citations cannot bypass review through exact or scored admission.
const ROUTING_POLICY_VERSION = 5;
// v6: ordered directives and singular named references share the same admission guard.
const ROUTING_POLICY_VERSION = 6;
const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']);
const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i;
@@ -37,37 +38,56 @@ const normalizedQueryName = text => text.replace(/n['\u2019]t\b/gi, ' not')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
// Classify each named skill locally: a rejected/quoted X must not suppress an
// affirmative Y in another clause. Ambiguous references stay available for the
// bounded proposal, but cannot admit themselves via either retrieval threshold.
function citationFor(candidate, query) {
if (!candidate.exact) return 'none';
const aliases = [...new Set([candidate.exactAlias, candidate.id.slice('skill:'.length)]
.filter(Boolean).map(normalizedQueryName))];
const quotedParts = query.match(/"[^"]*"|\u201c[^\u201d]*\u201d|(?:^|[\s(:])'[^']*'|\u2018[^\u2019]*\u2019|\x60[^\x60]*\x60/g) || [];
let directive = false;
let indirect = false;
for (const clause of query.match(/[^.!?;\n]+[.!?;\n]*/g) || []) {
/** Return canonical/native aliases plus conservative singular forms. Singular
* multiword aliases only guard indirect references; they never create exact admission. */
function citationAliases(candidate, nativeName) {
const names = [...new Set([candidate.exactAlias, candidate.id.slice('skill:'.length), nativeName]
.filter(Boolean).map(normalizedQueryName).filter(Boolean))];
return names.flatMap(name => [{ name, exact: true },
...(/\s\w+[^s]s$/.test(name) ? [{ name: name.slice(0, -1), exact: false }] : [])]);
}
/** Classify a retrieval candidate's references in query order. Genuine later
* directives/rejections replace earlier instructions; questions, quotations and
* reported speech do not withdraw a directive. Returns directive, indirect or none.
* @param {object} candidate Ranked skill candidate with its canonical ID and exact alias.
* @param {string} query Task text to classify without loading skill contents.
* @param {string} nativeName Registry name, including aliases absent from exact retrieval.
* @returns {'directive'|'indirect'|'none'} Admission evidence for this skill only.
*/
function citationFor(candidate, query, nativeName) {
const aliases = citationAliases(candidate, nativeName);
let citation = 'none';
// Mask quoted instructions before splitting clauses so punctuation inside a
// quotation cannot look like a separate instruction. Quoted names remain usable.
const unquoted = query.replace(/"[^"]*"|\u201c[^\u201d]*\u201d|(?:^|[\s(:])'[^']*'|\u2018[^\u2019]*\u2019|\x60[^\x60]*\x60/g, part => {
const text = normalizedQueryName(part);
if (!DIRECTIVE_VERB.test(text)) return part;
if (aliases.some(({ name }) => new RegExp('\\b' + name + '\\b').test(text))) citation = 'indirect';
return part.replace(/[^.!?;\n]/g, ' ');
});
for (const clause of unquoted.match(/[^.!?;\n]+[.!?;\n]*/g) || []) {
const text = normalizedQueryName(clause);
for (const name of aliases) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const directivePattern = DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?' + escaped + '\\b';
if (quotedParts.some(part => new RegExp(directivePattern, 'i').test(normalizedQueryName(part)))) indirect = true;
for (const mention of text.matchAll(new RegExp('\\b' + escaped + '\\b', 'gi'))) {
const prefix = text.slice(0, mention.index);
const polite = /^(?:(?:please )?(?:can|could|would|will) you (?:please )?|do )(?:use|apply|invoke|run|follow|load)\b/.test(text);
const question = !polite && (clause.includes('?')
|| /^(can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/.test(text));
const quoted = /^\s*["'\u201c\u2018\x60]/.test(clause);
if (question || quoted || /\b(do not|never|no|not|avoid|says|said|reads|told|mentions?|quoted?|document)\b/.test(prefix)) {
indirect = true;
} else if (new RegExp(DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?$', 'i').test(prefix)) {
directive = true;
}
const mentions = aliases.flatMap(({ name, exact }) => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))]
.map(mention => ({ index: mention.index, exact }))).sort((a, b) => a.index - b.index);
const polite = /^(?:(?:please )?(?:can|could|would|will) you (?:please )?|do )(?:use|apply|invoke|run|follow|load)\b/.test(text);
const question = !polite && (clause.includes('?')
|| (!/^do not\b/.test(text)
&& /^(can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/.test(text)));
for (const mention of mentions) {
const prefix = text.slice(0, mention.index);
if (question || /^\s*["'\u201c\u2018\x60]/.test(clause)
|| /\b(say|says|said|reads|told|mentions?|quoted?|document)\b/.test(prefix)) {
if (citation === 'none') citation = 'indirect';
} else if (/\b(do not|never|no|not|avoid)\b/.test(prefix)) {
citation = 'indirect';
} else if (mention.exact && candidate.exact
&& new RegExp(DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?$', 'i').test(prefix)) {
citation = 'directive';
}
}
}
return indirect ? 'indirect' : directive ? 'directive' : 'none';
return citation;
}
function validateTask(task) {
@@ -218,7 +238,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l
// questions, negations, reported speech, multiple cited names — never admit
// implicitly. Everything else keeps the bounded-proposal path so the
// primary agent decides ambiguous cases during work it was already doing.
const citations = new Map(candidates.map(candidate => [candidate.id, citationFor(candidate, task.query || '')]));
const citations = new Map(candidates.map(candidate => [candidate.id, citationFor(candidate, task.query || '', byId.get(candidate.id).name)]));
const exactAnchors = candidates.filter(candidate => citations.get(candidate.id) === 'directive');
let autoSelection = null;
if (!task.noWorkflow && selectionMode === 'auto' && !reused && !explicitIds.length && !proposedIds.length && candidates.length) {
+31 -1
View File
@@ -157,7 +157,7 @@ test('spawn failures and timeout signals remain unsuccessful without a native ex
}));
for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature skill.',
'Can I use the feature skill?', 'The README says use the feature skill.']) {
'Can I use the feature skill?', 'The README says use the feature skill.', 'The docs say use feature.']) {
test('indirect citation uses the proposal path and honors decline: ' + query, () => withFixture(repoRoot => {
const phases = [];
const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] },
@@ -173,3 +173,33 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature
assert.equal(result.selection.reason, 'agent-declined-selection');
}));
}
test('a final directive after a question reaches the task with its context', () => withFixture(repoRoot => {
const phases = [];
const result = launchTaskContext({ repoRoot,
task: { ...input, query: 'Should we use feature? Use feature.', explicitIds: [] },
execute(command, args, options) {
phases.push(options.phase);
assert.match(options.input, /# feature/);
return { status: 0, stdout: 'ok' };
} });
assert.deepEqual(phases, ['task']);
assert.equal(result.routingCalls, 0);
assert.deepEqual(result.selection.loadedIds, ['skill:feature']);
}));
test('a singular rejected name cannot inject scored context at launch', () => {
const phases = [];
const query = "Don't use the database migration skill. Review a PostgreSQL migration that adds an indexed nullable column without downtime.";
const result = launchTaskContext({ task: { ...input, query, explicitIds: [] },
execute(command, args, options) {
phases.push(options.phase);
if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' };
assert.doesNotMatch(options.input, /# Database Migration Patterns/);
return { status: 0, stdout: 'ok' };
} });
assert.deepEqual(phases, ['selection', 'task']);
assert.equal(result.routingCalls, 1);
assert.deepEqual(result.selection.loadedIds, []);
assert.equal(result.selection.fallback, null);
});
+63 -1
View File
@@ -319,6 +319,9 @@ for (const query of [
'Should we use the feature skill?',
'Why use the feature skill?',
'The README says to use the feature skill.',
'The docs say use feature.',
'The docs say to use feature.',
'The docs said use feature.',
'"Use the feature skill" is an example.',
]) {
test('indirect name citation needs an agent decision: ' + query, () => withFixture(repoRoot => {
@@ -372,7 +375,7 @@ test('routing policy changes invalidate receipts created by the old citation pol
const { compileContextProfile } = require('../../scripts/lib/context-profiles');
const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' });
const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1,
phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 4,
phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 5,
triggersDigest: digestObject({}), queryDigest: digestObject(query) });
const { receiptDigest: _receiptDigest, ...receipt } = first.receipt;
const oldReceipt = { ...receipt, bindingDigest, explicitIds: [],
@@ -382,3 +385,62 @@ test('routing policy changes invalidate receipts created by the old citation pol
assert.equal(result.reused, false);
assert.deepEqual(result.loadedIds, []);
}));
for (const query of [
'Should we use feature? Use feature.',
'Use feature. Should we use feature?',
'The docs say use feature. Use feature.',
'Use feature. The docs say use feature.',
'The phrase "Use feature." is an example. Use feature.',
'The docs say "Use feature." Use feature.',
'The docs say "Should we use feature?" Use feature.',
`Use feature. The docs say "don’t use feature."`,
'Use feature. The phrase "Use feature." is an example.',
"Don't use feature. Use feature.",
'The docs say never use feature. Use feature.',
'Use feature. The docs say never use feature.',
'Use feature to inspect the essay.',
]) {
test('a genuine directive survives surrounding discussion: ' + query, () => withFixture(repoRoot => {
const result = resolve(repoRoot, { query }, { load: true });
assert.deepEqual(result.loadedIds, ['skill:feature']);
assert.equal(result.reason, 'auto-selection');
assert.equal(result.receipt.autoSelection.exact, true);
}));
}
for (const query of [
"Use feature. Don't use feature.",
'Use feature. Never use feature.',
"Use feature. Don't use feature. Should we use feature?",
]) {
test('a later rejection withdraws an earlier directive: ' + query, () => withFixture(repoRoot => {
const result = resolve(repoRoot, { query }, { load: true });
assert.deepEqual(result.loadedIds, []);
assert.equal(result.fallback, null);
}));
}
for (const prefix of [
"Don't use the database migration skill.",
'Don\u2019t use the database migration skill.',
'Never use database migration.',
'Should we use the database migration skill?',
'The docs say use the database migration skill.',
]) {
test('singular named references cannot bypass citation review: ' + prefix, () => {
const query = prefix + ' Review a PostgreSQL migration that adds an indexed nullable column without downtime.';
const result = resolveTaskContext({ task: task({ query }), load: true });
const candidate = result.candidates.find(value => value.id === 'skill:database-migrations');
assert.ok(candidate && candidate.bm25 >= 20, 'Exercise the BM25 auto-admission threshold');
assert.equal(candidate.exact, false, 'Exercise the non-exact candidate path');
assert.deepEqual(result.loadedIds, []);
assert.equal(result.fallback, null);
});
}
test('singular forms do not create exact directive admission', () => {
const result = resolveTaskContext({ task: task({ query: 'Use the database migration skill.' }), load: true });
assert.ok(result.candidates.some(candidate => candidate.id === 'skill:database-migrations'));
assert.ok(!result.receipt.autoSelection || !result.receipt.autoSelection.exact);
});