mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-28 20:45:11 +02:00
274 lines
16 KiB
JavaScript
274 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const yaml = require('js-yaml');
|
|
const { loadContextRegistry, loadSkillTriggers } = require('./context-pack-registry');
|
|
const { compileContextProfile } = require('./context-profiles');
|
|
const { buildRetrievalIndex, searchRetrieval } = require('./context-retrieval');
|
|
const { DEFAULT_REPO_ROOT, createSourceReader, digestObject } = require('./context-profile-support');
|
|
|
|
const MAX_CANDIDATES = 5;
|
|
const MAX_SELECTED = 8;
|
|
const MAX_CONTEXT_BYTES = 32000;
|
|
// Auto-admission bar, calibrated on the pinned probe corpus in
|
|
// tests/lib/context-retrieval.test.js: admit the ranked top skill without a
|
|
// provider proposal only when the match is strong in absolute terms and
|
|
// clearly separated from the second candidate. Exact canonical-name anchors
|
|
// are admitted when exactly one skill is cited. Revisit these values when the
|
|
// pinned-embedder upgrade changes score distributions.
|
|
const AUTO_ADMIT_MIN_BM25 = 20;
|
|
const AUTO_ADMIT_MIN_TERMS = 3;
|
|
const AUTO_ADMIT_MARGIN = 1.5;
|
|
// Tier-2 fallback: when Auto defers to a provider proposal and a NON-EMPTY
|
|
// proposal admits nothing, admit the top candidate anyway if it clears this
|
|
// lower bar. An explicitly empty proposal is a decline and is honored — the
|
|
// task runs without injected context. Below the bar, no fallback exists —
|
|
// running without context is safer than loading a likely-wrong skill.
|
|
const FALLBACK_MIN_BM25 = 12;
|
|
const FALLBACK_MIN_TERMS = 2;
|
|
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.
|
|
const ROUTING_POLICY_VERSION = 4;
|
|
const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']);
|
|
|
|
function validateTask(task) {
|
|
if (!task || typeof task !== 'object' || Array.isArray(task)) throw new Error('Task must be an object');
|
|
for (const key of Object.keys(task)) if (!TASK_KEYS.has(key)) throw new Error(`Unknown task field: ${key}`);
|
|
for (const key of ['sessionId', 'taskId', 'phase']) {
|
|
if (typeof task[key] !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(task[key])) {
|
|
throw new Error(`Invalid task ${key}`);
|
|
}
|
|
}
|
|
if (!Number.isSafeInteger(task.revision) || task.revision < 1) throw new Error('Task revision must be a positive integer');
|
|
if (task.query !== undefined && (typeof task.query !== 'string' || Buffer.byteLength(task.query) > 8192)) {
|
|
throw new Error('Task query exceeds the input limit');
|
|
}
|
|
if (task.noWorkflow !== undefined && typeof task.noWorkflow !== 'boolean') throw new Error('noWorkflow must be boolean');
|
|
for (const key of ['explicitIds', 'proposedIds']) {
|
|
if (task[key] !== undefined && (!Array.isArray(task[key]) || task[key].length > MAX_SELECTED
|
|
|| task[key].some(id => typeof id !== 'string') || new Set(task[key]).size !== task[key].length)) {
|
|
throw new Error(`${key} must contain at most ${MAX_SELECTED} unique skill IDs`);
|
|
}
|
|
}
|
|
if (task.noWorkflow && ((task.explicitIds || []).length || (task.proposedIds || []).length)) {
|
|
throw new Error('noWorkflow conflicts with requested skills');
|
|
}
|
|
}
|
|
|
|
// Inspired by Jeffrey Montoya's bounded local routing in community PR #2945.
|
|
// Canonical source digests replace its independent cache/receipt authority.
|
|
// Ranking now uses the hybrid retrieval engine (BM25-weighted fields fused
|
|
// with hashed character n-gram vectors); see context-retrieval.js.
|
|
function candidatesFor(query, entries, excluded, admissible, triggers = {}) {
|
|
const available = entries.filter(entry => !excluded.has(entry.id))
|
|
.map(entry => triggers[entry.id] ? { ...entry, triggers: triggers[entry.id] } : entry);
|
|
const index = buildRetrievalIndex(available);
|
|
const candidates = searchRetrieval(index, query, { limit: MAX_CANDIDATES * 3 })
|
|
.filter(candidate => admissible(candidate.id))
|
|
.slice(0, MAX_CANDIDATES);
|
|
return { candidates };
|
|
}
|
|
|
|
function verifiedResource(entry, sourcePath, reader) {
|
|
const expected = entry.resources.find(resource => resource.path === sourcePath);
|
|
const actual = reader.read(sourcePath);
|
|
if (!expected || actual.digest !== expected.digest || actual.bytes !== expected.bytes) {
|
|
throw new Error('Context source changed during selection');
|
|
}
|
|
return actual;
|
|
}
|
|
|
|
function policyFor(entry, reader) {
|
|
const source = verifiedResource(entry, entry.sourcePath, reader).content.toString('utf8');
|
|
const match = source.replace(/\r\n?/g, '\n').match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
const metadata = match ? yaml.load(match[1], { schema: yaml.JSON_SCHEMA }) : {};
|
|
let manualOnly = metadata['disable-model-invocation'] === true;
|
|
const config = entry.resources.find(resource => resource.path.endsWith('/agents/openai.yaml'));
|
|
if (config) {
|
|
const document = yaml.load(verifiedResource(entry, config.path, reader).content.toString('utf8'), { schema: yaml.JSON_SCHEMA });
|
|
manualOnly ||= document?.policy?.allow_implicit_invocation === false;
|
|
}
|
|
return { manualOnly, authority: ['allowed-tools', 'tools', 'context', 'agent', 'hooks'].some(key => metadata[key] !== undefined),
|
|
dynamic: /!`/.test(source) };
|
|
}
|
|
|
|
function selectedClosure(ids, explicit, byId, excluded, reader) {
|
|
const selected = new Set();
|
|
function visit(id) {
|
|
if (!byId.has(id)) throw new Error(`Unknown context ID: ${id}`);
|
|
if (excluded.has(id)) throw new Error(`Context ID is excluded: ${id}`);
|
|
if (selected.has(id)) return;
|
|
const entry = byId.get(id);
|
|
const policy = policyFor(entry, reader);
|
|
if (policy.manualOnly && !explicit.has(id)) throw new Error(`Context ID is manual-only: ${id}`);
|
|
if (policy.authority || policy.dynamic) throw new Error(`Context requires native authority or dynamic-content review: ${id}`);
|
|
selected.add(id);
|
|
if (selected.size > MAX_SELECTED) throw new Error('Task selection exceeds the skill limit');
|
|
entry.dependencies.forEach(visit);
|
|
}
|
|
ids.forEach(visit);
|
|
return [...selected].sort();
|
|
}
|
|
|
|
function readSelected(ids, byId, reader) {
|
|
let total = 0;
|
|
return ids.flatMap(id => {
|
|
const entry = byId.get(id);
|
|
return [...new Set([entry.sourcePath, ...entry.requiredResources])].map(sourcePath => {
|
|
const actual = verifiedResource(entry, sourcePath, reader);
|
|
total += actual.bytes;
|
|
if (total > MAX_CONTEXT_BYTES) throw new Error('Task context exceeds the 32000-byte budget; choose a narrower immediate step');
|
|
const content = actual.content.toString('utf8');
|
|
if (!Buffer.from(content, 'utf8').equals(actual.content) || content.includes('\0')) throw new Error('Required context resource is not UTF-8 text');
|
|
return { id, path: sourcePath, digest: actual.digest, bytes: actual.bytes, content };
|
|
});
|
|
});
|
|
}
|
|
|
|
function validatePrevious(previous) {
|
|
if (!previous) return;
|
|
const { receiptDigest, ...value } = previous;
|
|
if (previous.schemaVersion !== 'ecc.task-context-receipt.v1' || digestObject(value) !== receiptDigest
|
|
|| !Array.isArray(previous.selectedIds) || !Array.isArray(previous.explicitIds)
|
|
|| (previous.decision !== undefined && !['pending', 'selected', 'none'].includes(previous.decision))) {
|
|
throw new Error('Invalid task context receipt');
|
|
}
|
|
}
|
|
|
|
/** Pure task-scoped resolver. Returned context never invokes a native skill or changes permissions. */
|
|
function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'lean@1', target = 'codex',
|
|
selectionMode = 'auto', include = [], exclude = [], load = false, previous = null, expectedDigest = null } = {}) {
|
|
validateTask(task);
|
|
validatePrevious(previous);
|
|
const plan = compileContextProfile({ repoRoot, profileId, target, selectionMode, include, exclude });
|
|
const registry = loadContextRegistry({ repoRoot });
|
|
const { triggers } = loadSkillTriggers({ repoRoot });
|
|
if (registry.registryDigest !== plan.registryDigest) throw new Error('Registry changed during task selection');
|
|
const reader = createSourceReader(repoRoot);
|
|
const byId = new Map(registry.entries.map(entry => [entry.id, entry]));
|
|
const excluded = new Set(plan.excludedIds);
|
|
const explicitIds = [...(task.explicitIds || [])].sort();
|
|
const proposedIds = [...(task.proposedIds || [])].sort();
|
|
[...explicitIds, ...proposedIds].forEach(id => {
|
|
if (!byId.has(id)) throw new Error(`Unknown context ID: ${id}`);
|
|
if (excluded.has(id)) throw new Error(`Context ID is excluded: ${id}`);
|
|
});
|
|
const taskBinding = { sessionId: task.sessionId, taskId: task.taskId, revision: task.revision, phase: task.phase };
|
|
const bindingDigest = digestObject({ ...taskBinding, planDigest: plan.planDigest,
|
|
routingPolicyVersion: ROUTING_POLICY_VERSION, triggersDigest: digestObject(triggers),
|
|
queryDigest: digestObject(task.query || '') });
|
|
const reused = Boolean(previous && previous.bindingDigest === bindingDigest && !task.noWorkflow
|
|
&& ['selected', 'none'].includes(previous.decision) && !explicitIds.length && !proposedIds.length);
|
|
const admissible = id => {
|
|
try {
|
|
const closure = selectedClosure([id], new Set(), byId, excluded, reader);
|
|
readSelected(closure, byId, reader);
|
|
return true;
|
|
} catch (error) {
|
|
// Only known admission denials remove a suggestion. Source drift and
|
|
// malformed policy still fail closed instead of disappearing from view.
|
|
if (/manual-only|requires native authority|is excluded|exceeds the skill limit|32000-byte budget|not UTF-8 text/.test(error.message)) return false;
|
|
throw error;
|
|
}
|
|
};
|
|
const { candidates } = task.noWorkflow || selectionMode === 'manual' || reused
|
|
? { candidates: [] } : candidatesFor(task.query || '', registry.entries, excluded, admissible, triggers);
|
|
// Auto admission: free-text routing loads the ranked top skill only on
|
|
// unambiguous evidence, or when the query is an explicit directive citation
|
|
// of exactly one skill (for example "Use the X skill"). Mere mentions —
|
|
// 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 DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i;
|
|
const normalizedQueryName = text => text.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ')
|
|
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
const directiveCitation = candidate => {
|
|
if (!candidate || !candidate.exact) return false;
|
|
const text = normalizedQueryName(task.query || '');
|
|
const aliases = [...new Set([candidate.exactAlias,
|
|
candidate.id.slice('skill:'.length).toLowerCase(),
|
|
candidate.id.slice('skill:'.length).toLowerCase().replace(/-/g, ' ')].filter(Boolean))];
|
|
for (const name of aliases) {
|
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const pattern = new RegExp(`${DIRECTIVE_VERB.source}(skill\\s*:?\\s*)?${escaped}(\\s+(skill|workflow|guidance))?\\b`, 'i');
|
|
const match = pattern.exec(text);
|
|
if (!match) continue;
|
|
const window = text.slice(Math.max(0, match.index - 28), match.index);
|
|
if (/\b(do not|don't|never|no)\b/.test(window)) return false;
|
|
if (/\b(says|said|reads|told|document)\b/i.test(task.query || '')) return false;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
const exactAnchors = candidates.filter(directiveCitation);
|
|
let autoSelection = null;
|
|
if (!task.noWorkflow && selectionMode === 'auto' && !reused && !explicitIds.length && !proposedIds.length && candidates.length) {
|
|
if (exactAnchors.length === 1) {
|
|
autoSelection = { id: exactAnchors[0].id, bm25: exactAnchors[0].bm25,
|
|
matchedTerms: exactAnchors[0].matchedTerms.length, exact: true };
|
|
} else if (!exactAnchors.length) {
|
|
const top = candidates[0];
|
|
const second = candidates[1];
|
|
if (top.bm25 >= AUTO_ADMIT_MIN_BM25 && top.matchedTerms.length >= AUTO_ADMIT_MIN_TERMS
|
|
&& (!second || top.bm25 >= AUTO_ADMIT_MARGIN * (second.bm25 || 0))) {
|
|
autoSelection = { id: top.id, bm25: top.bm25, matchedTerms: top.matchedTerms.length, exact: false };
|
|
}
|
|
}
|
|
}
|
|
let fallback = null;
|
|
if (!autoSelection && !task.noWorkflow && selectionMode === 'auto' && !reused
|
|
&& !explicitIds.length && !proposedIds.length && candidates.length && !exactAnchors.length) {
|
|
const top = candidates[0];
|
|
const second = candidates[1];
|
|
if (top.bm25 >= FALLBACK_MIN_BM25 && top.matchedTerms.length >= FALLBACK_MIN_TERMS
|
|
&& (!second || top.bm25 >= FALLBACK_MARGIN * (second.bm25 || 0))) {
|
|
fallback = { id: top.id, bm25: top.bm25, matchedTerms: top.matchedTerms.length };
|
|
}
|
|
}
|
|
const requested = task.noWorkflow ? [] : explicitIds.length ? explicitIds
|
|
: reused ? previous.selectedIds : selectionMode === 'manual' ? []
|
|
: proposedIds.length ? proposedIds : autoSelection ? [autoSelection.id] : [];
|
|
const effectiveExplicit = reused ? previous.explicitIds : explicitIds;
|
|
const selectedIds = selectedClosure(requested, new Set(effectiveExplicit), byId, excluded, reader);
|
|
const selectionDigest = digestObject({ bindingDigest, selectedIds, explicitIds: effectiveExplicit });
|
|
if (expectedDigest && expectedDigest !== selectionDigest) throw new Error('Task selection is stale; resolve again before loading');
|
|
const resources = load && selectionMode !== 'suggest' ? readSelected(selectedIds, byId, reader) : [];
|
|
const loadedIds = [...new Set(resources.map(resource => resource.id))].sort();
|
|
const reason = task.noWorkflow ? 'no-workflow-needed' : reused ? 'reused-pinned-selection'
|
|
: explicitIds.length ? 'explicit-selection' : autoSelection ? 'auto-selection'
|
|
: proposedIds.length && selectedIds.length ? 'bounded-local-selection'
|
|
: candidates.length ? 'agent-selection-required' : 'no-selection';
|
|
const decision = selectedIds.length ? 'selected' : reason === 'agent-selection-required' ? 'pending' : 'none';
|
|
const receiptValue = { schemaVersion: 'ecc.task-context-receipt.v1', ...taskBinding, bindingDigest,
|
|
selectionDigest, profileId: plan.profileId, selectionMode, target, registryDigest: registry.registryDigest,
|
|
decision, selectedIds, explicitIds: effectiveExplicit, loadedIds,
|
|
resources: resources.map(({ content: _content, ...resource }) => resource) };
|
|
if (autoSelection) receiptValue.autoSelection = autoSelection;
|
|
return { schemaVersion: 'ecc.task-context.v1', profileId: plan.profileId, selectionMode, target,
|
|
reason, reused, selectedIds, loadedIds, candidates, resources, fallback,
|
|
activation: loadedIds.length ? 'context-returned' : 'proposed', nativeInvocation: 'unobserved',
|
|
enforcement: 'prompt-advisory', maxContextBytes: MAX_CONTEXT_BYTES,
|
|
receipt: { ...receiptValue, receiptDigest: digestObject(receiptValue) },
|
|
limitations: ['Context returned by this command is data for the calling agent; native invocation and execution are unobserved.',
|
|
'Auto mode admits a ranked skill only on calibrated unambiguous evidence or a single cited skill name; ambiguous routing still requires an explicit ID or an admitted agent proposal.',
|
|
'Selection grants no tools, hooks, network access, installation or persistent configuration changes.',
|
|
'The byte cap is an output bound, not a measured native token budget. Declared workflow dependencies remain incomplete.'] };
|
|
}
|
|
|
|
/** After a bounded proposal admitted nothing despite proposing a candidate,
|
|
* admit the tier-2 fallback candidate so a task with decent local evidence
|
|
* never runs with zero context. Callers must NOT invoke this for an explicit
|
|
* decline (an empty proposal is honored as-is). Returns the original
|
|
* selection when no fallback exists or it cannot be admitted. */
|
|
function resolveDeclinedFallback(options, selection) {
|
|
if (!selection || selection.reason !== 'agent-selection-required' || !selection.fallback) return selection;
|
|
const resolved = resolveTaskContext({ ...options, task: { ...options.task, proposedIds: [selection.fallback.id] } });
|
|
if (!resolved.selectedIds.length) return selection;
|
|
const receiptValue = { ...resolved.receipt, fallbackApplied: true };
|
|
delete receiptValue.receiptDigest;
|
|
return { ...resolved, reason: 'auto-selection-fallback',
|
|
receipt: { ...receiptValue, receiptDigest: digestObject(receiptValue) } };
|
|
}
|
|
|
|
module.exports = { resolveTaskContext, resolveDeclinedFallback };
|