mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-21 15:31:46 +02:00
fix(context): honor directive order and singular rejections
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user