OpenWriterControle/evals/framework/src/evaluators/context-loading-evaluator.ts
Darren Hinde 926d68de93
fix(evals): repair deterministic eval baseline (behavioral regressions + tsc build) (#334)
* fix(evals): repair six eval-framework behavioral regressions

Restore the deterministic eval baseline by fixing false-negatives and
identity/discovery bugs across the framework:

- error-handling: correct severity-boundary fixtures so mixed-severity
  cases actually cross the >1000 warning threshold (deterministic
  error/warning/info), plus explicit 1000-vs-1001 boundary tests.
- context-loading-evaluator: emit `no-context-loaded` and
  `context-loaded-after-execution` as errors so missing/late context
  fails (was a passing warning); update focused test accordingly.
- tool-usage-evaluator: enforce dedicated tools — bash `cat`/`ls`/`ls -la`
  now fail as `bash-antipattern-*` errors (ls de-allowlisted); npm/git/
  piped commands still pass.
- test-case-loader: canonicalize legacy agent ids via normalizeAgentId
  (`openagent` -> `core/openagent`), preserving canonical/unknown ids.
- suite-validator: accept category-based agent ids, resolve test paths
  under the canonical dir, add requested-vs-declared conflict detection;
  new suite-validator.test.ts.
- validate-suites-cli: recursive suite discovery (nested <cat>/<agent>/
  config), deterministic order, fail-closed on zero suites; new
  validate-suites-cli.test.ts.

Deterministic Vitest allowlist green; validate:suites:all non-vacuous
and exits 0. No model/network/eval:sdk execution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(evals): clear pre-existing TypeScript build errors (tsc 39 -> 0)

`npm run build` (tsc) failed on 39 pre-existing errors in files outside
the behavioral-repair scope, blocking the baseline gate. Root causes:

- glob: drop obsolete @types/glob ^8 so glob v13's own types (globSync)
  resolve (fixes run-sdk-tests.ts).
- task-type-detector: introduce a local DetectedTaskType union for this
  (dead/unused) detector; shared TaskType union left unchanged.
- behavior-evaluator: the expectedResponse check filtered a nonexistent
  `message` event type and was silently dead — route through
  base-evaluator.getAssistantMessages (assistant_message/text). Adds
  three deterministic tests exercising the now-live check.
- integration tests: remove unused `expectedOutcome` fixture metadata
  (not on TestCase, never asserted) and add the required `category`
  field the excess-property error had masked.

tsc now exits 0. Vitest allowlist 112/112. Lint remains unconfigured in
evals/framework (no ESLint config has ever existed) and is deferred to a
separate follow-up; it is not part of this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(evals): sync bun.lock after @types/glob removal

The previous commit removed @types/glob via npm, updating package-lock.json
but leaving the primary bun.lock stale, which broke CI's
`bun install --frozen-lockfile`. Regenerate bun.lock so both lockfiles
agree (drops @types/glob and its transitive @types/minimatch only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(evals): add evals/framework/package-lock.json for build-check

The pr-checks `build-check` job caches on and runs `npm ci` in
evals/framework, but that workspace had no local lockfile, so the job
failed at Setup Node.js ("Some specified paths were not resolved") and
would fail `npm ci`. Add a standalone, in-sync package-lock.json
(lockfileVersion 3, no @types/glob) so the job's cache path resolves and
`npm ci` succeeds. Validated locally with `npm ci --dry-run`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:22:27 +01:00

541 lines
18 KiB
TypeScript

/**
* ContextLoadingEvaluator - Verifies context files are loaded before execution
*
* Rules:
* 1. Before executing tasks, agents should load relevant context files
* 2. Context files include:
* - .opencode/agent/*.md (agent definitions)
* - .opencode/context/*.md (domain knowledge, standards, processes)
* - docs/*.md (project documentation)
* 3. Context should be loaded BEFORE execution tools are called
* 4. Exception: Read-only conversational sessions don't require context loading
*
* Checks:
* - Detect if session involves execution (bash/write/edit/task)
* - Check if context files were read before execution
* - Track which context files were loaded
* - Report violations where execution happens without context
*/
import { BaseEvaluator } from './base-evaluator.js';
import {
TimelineEvent,
SessionInfo,
EvaluationResult,
Violation,
Evidence,
Check,
ContextLoadingCheck,
TaskType
} from '../types/index.js';
import type { BehaviorExpectation } from '../sdk/test-case-schema.js';
export class ContextLoadingEvaluator extends BaseEvaluator {
name = 'context-loading';
description = 'Verifies context files are loaded before task execution';
private behaviorConfig?: BehaviorExpectation;
constructor(behaviorConfig?: BehaviorExpectation) {
super();
this.behaviorConfig = behaviorConfig;
}
// Context file patterns
private contextPatterns = [
/\.opencode\/agent\/.*\.md$/,
/\.opencode\/context\/.*\.md$/,
/docs\/.*\.md$/,
/standards\/.*\.md$/, // Allow standards/ paths
/workflows\/.*\.md$/, // Allow workflows/ paths
/CONTRIBUTING\.md$/,
/README\.md$/
];
/**
* Context file mapping per task type
* Maps task types to their required context files (with flexible matching)
*/
private readonly CONTEXT_FILE_MAP: Record<TaskType, string[]> = {
'code': [
'.opencode/context/core/standards/code.md',
'standards/code.md',
'code.md'
],
'docs': [
'.opencode/context/core/standards/docs.md',
'standards/docs.md',
'docs.md'
],
'tests': [
'.opencode/context/core/standards/tests.md',
'standards/tests.md',
'tests.md'
],
'review': [
'.opencode/context/core/workflows/review.md',
'workflows/review.md',
'review.md'
],
'delegation': [
'.opencode/context/core/workflows/delegation.md',
'workflows/delegation.md',
'delegation.md'
],
'bash-only': [], // No context required
'unknown': [] // Any context file acceptable
};
/**
* Classify task type from user message and tool calls
*/
private classifyTaskType(userMessage: string, executionTools: TimelineEvent[]): TaskType {
const message = userMessage.toLowerCase();
// Check for bash-only (no write/edit/task tools)
const hasFileModification = executionTools.some(tool =>
tool.data?.tool === 'write' ||
tool.data?.tool === 'edit' ||
tool.data?.tool === 'task'
);
if (!hasFileModification) {
const allBash = executionTools.every(tool => tool.data?.tool === 'bash');
if (allBash && executionTools.length > 0) {
return 'bash-only';
}
}
// Check for delegation
const hasTaskTool = executionTools.some(tool => tool.data?.tool === 'task');
if (hasTaskTool) {
return 'delegation';
}
// Classify by message content (order matters - most specific first)
const patterns: [RegExp, TaskType][] = [
[/test|spec|jest|vitest|mocha|pytest|unittest/i, 'tests'],
[/document|readme|docs|jsdoc|tsdoc|docstring/i, 'docs'],
[/review|audit|check|analyze|inspect/i, 'review'],
[/create|implement|write|add|build|develop|code|function|class|component/i, 'code'],
[/refactor|fix|update|modify|change|edit/i, 'code'],
];
for (const [pattern, taskType] of patterns) {
if (pattern.test(message)) {
return taskType;
}
}
return 'unknown';
}
/**
* Validate that the correct context file was loaded for the task type
*/
private validateContextFileForTask(
contextReads: Array<{ filePath: string; timestamp: number }>,
taskType: TaskType
): {
passed: boolean;
expected: string[];
actual: string[];
matchedFile?: string;
} {
const expectedFiles = this.CONTEXT_FILE_MAP[taskType];
const actualFiles = contextReads.map(r => r.filePath);
// Bash-only doesn't require specific context
if (taskType === 'bash-only') {
return {
passed: true,
expected: [],
actual: actualFiles,
matchedFile: undefined
};
}
// Unknown tasks - any context file is acceptable
if (taskType === 'unknown' || expectedFiles.length === 0) {
return {
passed: actualFiles.length > 0,
expected: ['any context file'],
actual: actualFiles,
matchedFile: actualFiles[0]
};
}
// Check if any loaded file matches expected patterns
for (const actualFile of actualFiles) {
for (const expectedPattern of expectedFiles) {
if (actualFile.includes(expectedPattern) || actualFile.endsWith(expectedPattern)) {
return {
passed: true,
expected: expectedFiles,
actual: actualFiles,
matchedFile: actualFile
};
}
}
}
// No match found - wrong context file loaded
return {
passed: false,
expected: expectedFiles,
actual: actualFiles,
matchedFile: undefined
};
}
async evaluate(timeline: TimelineEvent[], sessionInfo: SessionInfo): Promise<EvaluationResult> {
const checks: Check[] = [];
const violations: Violation[] = [];
const evidence: Evidence[] = [];
// Check if this is a task session (has execution tools)
const executionTools = this.getExecutionTools(timeline);
const isTaskSession = executionTools.length > 0;
if (!isTaskSession) {
// Conversational session - context loading not required
checks.push({
name: 'conversational-session',
passed: true,
weight: 100,
evidence: [
this.createEvidence(
'session-type',
'Conversational session - context loading not required',
{ executionToolCount: 0 }
)
]
});
return this.buildResult(this.name, checks, violations, evidence, {
isTaskSession: false,
executionToolCount: 0
});
}
// Check if this is a bash-only task (openagent.md line 172, 184)
// Bash-only tasks don't require context files
const isBashOnly = this.isBashOnlyTask(executionTools);
if (isBashOnly) {
checks.push({
name: 'bash-only-task',
passed: true,
weight: 100,
evidence: [
this.createEvidence(
'task-type',
'Bash-only task - context loading not required (openagent.md line 172, 184)',
{ executionToolCount: executionTools.length, onlyBash: true }
)
]
});
return this.buildResult(this.name, checks, violations, evidence, {
isTaskSession: true,
isBashOnly: true,
executionToolCount: executionTools.length
});
}
// Get user message for task classification
const userMessages = this.getUserMessages(timeline);
const firstUserMessage = userMessages[0]?.data?.text || userMessages[0]?.data?.content || '';
// Classify task type
const taskType = this.classifyTaskType(firstUserMessage, executionTools);
// Get all read tool calls
const readTools = this.getReadTools(timeline);
// Find context file reads
const contextReads = this.findContextReads(readTools);
// Validate correct context file for task type
const contextValidation = this.validateContextFileForTask(contextReads, taskType);
// For multi-turn sessions, check if ANY context was loaded at ANY point
// This is more lenient for complex conversations where context might be loaded
// in response to different prompts
const hasAnyContextLoaded = contextReads.length > 0;
// Check if context was loaded before first execution
const firstExecution = executionTools[0];
const contextLoadedBeforeFirstExecution = this.wasContextLoadedBefore(
contextReads,
firstExecution.timestamp
);
// For multi-turn: Check each execution that requires context
const executionsRequiringContext = executionTools.filter(tool =>
tool.data?.tool === 'write' ||
tool.data?.tool === 'edit' ||
tool.data?.tool === 'task'
);
let allExecutionsHaveContext = true;
const executionChecks: string[] = [];
for (const execution of executionsRequiringContext) {
const hasContextBefore = this.wasContextLoadedBefore(contextReads, execution.timestamp);
executionChecks.push(
`${execution.data?.tool} at ${new Date(execution.timestamp).toISOString()}: ${hasContextBefore ? '✓' : '✗'}`
);
if (!hasContextBefore) {
allExecutionsHaveContext = false;
}
}
// Build check with task type validation
const check: ContextLoadingCheck = {
contextFileLoaded: hasAnyContextLoaded && allExecutionsHaveContext && contextValidation.passed,
contextFilePath: contextValidation.matchedFile || (contextReads.length > 0 ? contextReads[0].filePath : undefined),
loadTimestamp: contextReads.length > 0 ? contextReads[0].timestamp : undefined,
executionTimestamp: firstExecution.timestamp,
taskType,
expectedContextFiles: contextValidation.expected,
actualContextFiles: contextValidation.actual,
evidence: []
};
if (hasAnyContextLoaded) {
// Show detection mode
const detectionMode = this.behaviorConfig?.expectedContextFiles && this.behaviorConfig.expectedContextFiles.length > 0
? 'Explicit (from YAML test)'
: 'Auto-detect (from user message)';
check.evidence.push(
`Task type: ${taskType}`,
`Expected context: ${contextValidation.expected.length > 0 ? contextValidation.expected.join(' or ') : 'none'}`,
``,
`Context files loaded: ${contextReads.length}`,
...contextReads.map(r => ` - ${r.filePath} at ${new Date(r.timestamp).toISOString()}`),
``
);
if (contextValidation.passed) {
check.evidence.push(`✓ Correct context file loaded for task type '${taskType}'`);
} else if (taskType !== 'bash-only' && contextValidation.expected.length > 0) {
check.evidence.push(`✗ Wrong context file - expected: ${contextValidation.expected.join(' or ')}`);
}
check.evidence.push(
``,
`Execution checks (${executionsRequiringContext.length} total):`,
...executionChecks
);
if (allExecutionsHaveContext) {
check.evidence.push(``, `✓ All executions have context loaded before them`);
} else {
check.evidence.push(``, `✗ Some executions missing context`);
}
} else {
check.evidence.push(
`Task type: ${taskType}`,
`Expected context: ${contextValidation.expected.length > 0 ? contextValidation.expected.join(' or ') : 'none'}`,
``,
`No context files loaded in session`,
`First execution: ${new Date(firstExecution.timestamp).toISOString()}`,
`Execution tool: ${firstExecution.data?.tool}`
);
}
// Add check result
checks.push({
name: 'context-loaded-before-execution',
passed: hasAnyContextLoaded && allExecutionsHaveContext && contextValidation.passed,
weight: 100,
evidence: check.evidence.map(e =>
this.createEvidence('context-check', e, {
contextFiles: contextReads.map(r => r.filePath),
executionTool: firstExecution.data?.tool,
totalExecutions: executionsRequiringContext.length,
executionsWithContext: executionChecks.filter(c => c.includes('✓')).length,
taskType,
expectedContext: contextValidation.expected,
actualContext: contextValidation.actual
})
)
});
// Add violation if context not loaded properly
if (!hasAnyContextLoaded) {
violations.push(
this.createViolation(
'no-context-loaded',
'error',
'Task execution started without loading any context files',
firstExecution.timestamp,
{
executionTool: firstExecution.data?.tool,
timestamp: firstExecution.timestamp,
contextFilesRead: 0,
taskType,
expectedContext: contextValidation.expected
}
)
);
} else if (!contextValidation.passed && taskType !== 'bash-only' && taskType !== 'unknown') {
// Wrong context file loaded
violations.push(
this.createViolation(
'wrong-context-file',
'error',
`Task type '${taskType}' requires context file(s): ${contextValidation.expected.join(' or ')}. ` +
`Loaded: ${contextValidation.actual.length > 0 ? contextValidation.actual.join(', ') : 'none'}`,
firstExecution.timestamp,
{
taskType,
expected: contextValidation.expected,
actual: contextValidation.actual,
executionTool: firstExecution.data?.tool
}
)
);
} else if (!allExecutionsHaveContext) {
violations.push(
this.createViolation(
'context-loaded-after-execution',
'error',
'Some executions happened before context was loaded',
firstExecution.timestamp,
{
totalExecutions: executionsRequiringContext.length,
executionsWithContext: executionChecks.filter(c => c.includes('✓')).length,
contextFilesRead: contextReads.length
}
)
);
}
// Add evidence
evidence.push(
this.createEvidence(
'context-files',
`Found ${contextReads.length} context file reads`,
{
contextFiles: contextReads.map(r => ({
path: r.filePath,
timestamp: r.timestamp
}))
}
)
);
evidence.push(
this.createEvidence(
'execution-tools',
`Found ${executionTools.length} execution tool calls`,
{
tools: executionTools.map(t => ({
tool: t.data?.tool,
timestamp: t.timestamp
}))
}
)
);
return this.buildResult(this.name, checks, violations, evidence, {
isTaskSession: true,
executionToolCount: executionTools.length,
contextFileCount: contextReads.length,
contextLoadedBeforeExecution: hasAnyContextLoaded && allExecutionsHaveContext && contextValidation.passed,
contextCheck: check,
multiTurn: executionsRequiringContext.length > 1,
executionsRequiringContext: executionsRequiringContext.length,
executionsWithContext: executionChecks.filter(c => c.includes('✓')).length,
taskType,
expectedContextFiles: contextValidation.expected,
actualContextFiles: contextValidation.actual,
correctContextLoaded: contextValidation.passed
});
}
/**
* Find all context file reads in timeline
*/
private findContextReads(readTools: TimelineEvent[]): Array<{
filePath: string;
timestamp: number;
}> {
const contextReads: Array<{ filePath: string; timestamp: number }> = [];
for (const tool of readTools) {
// Try multiple possible locations for file path
const filePath = tool.data?.state?.input?.filePath ||
tool.data?.state?.input?.path ||
tool.data?.input?.filePath ||
tool.data?.input?.path ||
tool.data?.filePath ||
tool.data?.path;
if (filePath && this.isContextFile(filePath)) {
contextReads.push({
filePath,
timestamp: tool.timestamp
});
}
}
// Sort by timestamp (earliest first)
return contextReads.sort((a, b) => a.timestamp - b.timestamp);
}
/**
* Check if file path is a context file
*/
private isContextFile(filePath: string): boolean {
return this.contextPatterns.some(pattern => pattern.test(filePath));
}
/**
* Check if context was loaded before a timestamp
*/
private wasContextLoadedBefore(
contextReads: Array<{ filePath: string; timestamp: number }>,
timestamp: number
): boolean {
return contextReads.some(read => read.timestamp < timestamp);
}
/**
* Get required context file for a task type
*/
private getRequiredContext(userMessage: string): string | undefined {
// Simple heuristic - could be enhanced
if (/test|spec|jest|vitest/i.test(userMessage)) {
return '.opencode/context/testing.md';
}
if (/document|readme|docs/i.test(userMessage)) {
return '.opencode/context/documentation.md';
}
if (/code|implement|feature|refactor/i.test(userMessage)) {
return '.opencode/context/standards.md';
}
return undefined;
}
/**
* Check if task is bash-only (no write/edit/task tools)
* Per openagent.md line 172, 184: "bash-only → No context needed"
*/
private isBashOnlyTask(executionTools: TimelineEvent[]): boolean {
// Check if ALL execution tools are bash
const allBash = executionTools.every(tool => tool.data?.tool === 'bash');
// Check if there are NO write/edit/task tools
const hasFileModification = executionTools.some(tool =>
tool.data?.tool === 'write' ||
tool.data?.tool === 'edit' ||
tool.data?.tool === 'task'
);
return allBash && !hasFileModification;
}
}