behavior-evaluator.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. /**
  2. * BehaviorEvaluator - Validates expected agent behavior from test cases
  3. *
  4. * This evaluator checks if the agent performed the expected actions:
  5. * - Used required tools (mustUseTools)
  6. * - Avoided forbidden tools (mustNotUseTools)
  7. * - Made minimum/maximum number of tool calls
  8. * - Requested approval when required
  9. * - Loaded context when required
  10. * - Delegated to subagents when required
  11. *
  12. * This is different from rule-based evaluators which check for violations.
  13. * This evaluator checks if the agent completed the task as expected.
  14. */
  15. import { BaseEvaluator } from './base-evaluator.js';
  16. import {
  17. TimelineEvent,
  18. SessionInfo,
  19. EvaluationResult,
  20. Violation,
  21. Evidence,
  22. Check,
  23. } from '../types/index.js';
  24. // Re-export from test-case-schema for backwards compatibility
  25. // The canonical definition is in test-case-schema.ts
  26. import type { BehaviorExpectation } from '../sdk/test-case-schema.js';
  27. export type { BehaviorExpectation };
  28. export class BehaviorEvaluator extends BaseEvaluator {
  29. name = 'behavior';
  30. description = 'Validates agent behavior matches test expectations';
  31. private behavior: BehaviorExpectation;
  32. constructor(behavior: BehaviorExpectation) {
  33. super();
  34. this.behavior = behavior;
  35. }
  36. async evaluate(timeline: TimelineEvent[], sessionInfo: SessionInfo): Promise<EvaluationResult> {
  37. const checks: Check[] = [];
  38. const violations: Violation[] = [];
  39. const evidence: Evidence[] = [];
  40. // Get all tool calls
  41. const toolCalls = this.getToolCalls(timeline);
  42. // Extract tool names - handle both direct and nested data structures
  43. // Tool name can be in: data.tool, data.state.tool, or the part itself
  44. const toolsUsed = toolCalls.map(tc => {
  45. const data = tc.data;
  46. if (!data) return null;
  47. // Try multiple paths where tool name might be stored
  48. return data.tool || data.state?.tool || null;
  49. }).filter((t): t is string => t !== null);
  50. const uniqueTools = [...new Set(toolsUsed)];
  51. // Log tool usage summary
  52. console.log(`\n${'='.repeat(60)}`);
  53. console.log(`BEHAVIOR VALIDATION`);
  54. console.log(`${'='.repeat(60)}`);
  55. console.log(`Timeline Events: ${timeline.length}`);
  56. console.log(`Tool Calls: ${toolCalls.length}`);
  57. console.log(`Tools Used: ${uniqueTools.join(', ') || 'none'}`);
  58. // Log each tool call with details
  59. if (toolCalls.length > 0) {
  60. console.log(`\nTool Call Details:`);
  61. toolCalls.forEach((tc, i) => {
  62. const tool = tc.data?.tool || 'unknown';
  63. const input = tc.data?.state?.input || tc.data?.input || {};
  64. // Show more details for task tool (delegation)
  65. if (tool === 'task') {
  66. console.log(` ${i + 1}. ${tool}:`);
  67. if (input.subagent_type) {
  68. console.log(` → Subagent: ${input.subagent_type}`);
  69. }
  70. if (input.description) {
  71. console.log(` → Description: ${input.description}`);
  72. }
  73. if (input.prompt) {
  74. const promptPreview = input.prompt.substring(0, 150);
  75. console.log(` → Prompt: "${promptPreview}${input.prompt.length > 150 ? '...' : ''}"`);
  76. }
  77. } else {
  78. // Regular tool - show compact format
  79. const inputStr = JSON.stringify(input).substring(0, 100);
  80. console.log(` ${i + 1}. ${tool}: ${inputStr}${inputStr.length >= 100 ? '...' : ''}`);
  81. }
  82. });
  83. }
  84. // Check 1: mustUseTools
  85. if (this.behavior.mustUseTools && this.behavior.mustUseTools.length > 0) {
  86. const missingTools: string[] = [];
  87. for (const requiredTool of this.behavior.mustUseTools) {
  88. const wasUsed = toolsUsed.includes(requiredTool);
  89. if (!wasUsed) {
  90. missingTools.push(requiredTool);
  91. violations.push(
  92. this.createViolation(
  93. 'missing-required-tool',
  94. 'error',
  95. `Required tool '${requiredTool}' was not used`,
  96. Date.now(),
  97. {
  98. requiredTool,
  99. toolsUsed: uniqueTools,
  100. }
  101. )
  102. );
  103. }
  104. }
  105. checks.push({
  106. name: 'must-use-tools',
  107. passed: missingTools.length === 0,
  108. weight: 100,
  109. evidence: [
  110. this.createEvidence(
  111. 'required-tools',
  112. missingTools.length === 0
  113. ? `All required tools used: ${this.behavior.mustUseTools.join(', ')}`
  114. : `Missing required tools: ${missingTools.join(', ')}`,
  115. {
  116. required: this.behavior.mustUseTools,
  117. used: uniqueTools,
  118. missing: missingTools,
  119. }
  120. )
  121. ]
  122. });
  123. }
  124. // Check 1b: mustUseAnyOf - at least one tool set must be fully used
  125. if (this.behavior.mustUseAnyOf && this.behavior.mustUseAnyOf.length > 0) {
  126. // Check if any of the tool sets is fully satisfied
  127. const satisfiedSets: string[][] = [];
  128. const unsatisfiedSets: { set: string[]; missing: string[] }[] = [];
  129. for (const toolSet of this.behavior.mustUseAnyOf) {
  130. const missingFromSet = toolSet.filter(tool => !toolsUsed.includes(tool));
  131. if (missingFromSet.length === 0) {
  132. satisfiedSets.push(toolSet);
  133. } else {
  134. unsatisfiedSets.push({ set: toolSet, missing: missingFromSet });
  135. }
  136. }
  137. const passed = satisfiedSets.length > 0;
  138. if (!passed) {
  139. violations.push(
  140. this.createViolation(
  141. 'missing-required-tool-set',
  142. 'error',
  143. `None of the required tool sets were fully used. Options: ${this.behavior.mustUseAnyOf.map(s => `[${s.join(', ')}]`).join(' OR ')}`,
  144. Date.now(),
  145. {
  146. requiredSets: this.behavior.mustUseAnyOf,
  147. toolsUsed: uniqueTools,
  148. unsatisfiedSets,
  149. }
  150. )
  151. );
  152. }
  153. checks.push({
  154. name: 'must-use-any-of',
  155. passed,
  156. weight: 100,
  157. evidence: [
  158. this.createEvidence(
  159. 'alternative-tools',
  160. passed
  161. ? `Satisfied tool set: [${satisfiedSets[0].join(', ')}]`
  162. : `No tool set satisfied. Options: ${this.behavior.mustUseAnyOf.map(s => `[${s.join(', ')}]`).join(' OR ')}`,
  163. {
  164. requiredSets: this.behavior.mustUseAnyOf,
  165. used: uniqueTools,
  166. satisfiedSets,
  167. unsatisfiedSets,
  168. }
  169. )
  170. ]
  171. });
  172. }
  173. // Check 2: mustNotUseTools
  174. if (this.behavior.mustNotUseTools && this.behavior.mustNotUseTools.length > 0) {
  175. const forbiddenToolsUsed: string[] = [];
  176. for (const forbiddenTool of this.behavior.mustNotUseTools) {
  177. const wasUsed = toolsUsed.includes(forbiddenTool);
  178. if (wasUsed) {
  179. forbiddenToolsUsed.push(forbiddenTool);
  180. violations.push(
  181. this.createViolation(
  182. 'forbidden-tool-used',
  183. 'error',
  184. `Forbidden tool '${forbiddenTool}' was used`,
  185. Date.now(),
  186. {
  187. forbiddenTool,
  188. toolsUsed: uniqueTools,
  189. }
  190. )
  191. );
  192. }
  193. }
  194. checks.push({
  195. name: 'must-not-use-tools',
  196. passed: forbiddenToolsUsed.length === 0,
  197. weight: 100,
  198. evidence: [
  199. this.createEvidence(
  200. 'forbidden-tools',
  201. forbiddenToolsUsed.length === 0
  202. ? `No forbidden tools used`
  203. : `Forbidden tools used: ${forbiddenToolsUsed.join(', ')}`,
  204. {
  205. forbidden: this.behavior.mustNotUseTools,
  206. used: uniqueTools,
  207. violations: forbiddenToolsUsed,
  208. }
  209. )
  210. ]
  211. });
  212. }
  213. // Check 3: minToolCalls
  214. if (this.behavior.minToolCalls !== undefined) {
  215. const passed = toolCalls.length >= this.behavior.minToolCalls;
  216. if (!passed) {
  217. violations.push(
  218. this.createViolation(
  219. 'insufficient-tool-calls',
  220. 'error',
  221. `Expected at least ${this.behavior.minToolCalls} tool calls, got ${toolCalls.length}`,
  222. Date.now(),
  223. {
  224. expected: this.behavior.minToolCalls,
  225. actual: toolCalls.length,
  226. }
  227. )
  228. );
  229. }
  230. checks.push({
  231. name: 'min-tool-calls',
  232. passed,
  233. weight: 50,
  234. evidence: [
  235. this.createEvidence(
  236. 'tool-call-count',
  237. `Tool calls: ${toolCalls.length} (min: ${this.behavior.minToolCalls})`,
  238. {
  239. actual: toolCalls.length,
  240. minimum: this.behavior.minToolCalls,
  241. }
  242. )
  243. ]
  244. });
  245. }
  246. // Check 4: maxToolCalls
  247. if (this.behavior.maxToolCalls !== undefined) {
  248. const passed = toolCalls.length <= this.behavior.maxToolCalls;
  249. if (!passed) {
  250. violations.push(
  251. this.createViolation(
  252. 'excessive-tool-calls',
  253. 'warning',
  254. `Expected at most ${this.behavior.maxToolCalls} tool calls, got ${toolCalls.length}`,
  255. Date.now(),
  256. {
  257. expected: this.behavior.maxToolCalls,
  258. actual: toolCalls.length,
  259. }
  260. )
  261. );
  262. }
  263. checks.push({
  264. name: 'max-tool-calls',
  265. passed,
  266. weight: 50,
  267. evidence: [
  268. this.createEvidence(
  269. 'tool-call-count',
  270. `Tool calls: ${toolCalls.length} (max: ${this.behavior.maxToolCalls})`,
  271. {
  272. actual: toolCalls.length,
  273. maximum: this.behavior.maxToolCalls,
  274. }
  275. )
  276. ]
  277. });
  278. }
  279. // Check 5: requiresApproval
  280. if (this.behavior.requiresApproval) {
  281. // Check if agent asked for approval (contains approval language in messages)
  282. const assistantMessages = this.getAssistantMessages(timeline);
  283. const hasApprovalRequest = assistantMessages.some(msg => {
  284. const text = msg.data?.text || '';
  285. return this.containsApprovalLanguage(text);
  286. });
  287. if (!hasApprovalRequest) {
  288. violations.push(
  289. this.createViolation(
  290. 'missing-approval-request',
  291. 'error',
  292. 'Agent did not request approval before executing',
  293. Date.now(),
  294. {
  295. requiresApproval: true,
  296. approvalRequested: false,
  297. }
  298. )
  299. );
  300. }
  301. checks.push({
  302. name: 'requires-approval',
  303. passed: hasApprovalRequest,
  304. weight: 100,
  305. evidence: [
  306. this.createEvidence(
  307. 'approval-request',
  308. hasApprovalRequest
  309. ? 'Agent requested approval before executing'
  310. : 'Agent did not request approval',
  311. {
  312. requiresApproval: true,
  313. approvalRequested: hasApprovalRequest,
  314. }
  315. )
  316. ]
  317. });
  318. }
  319. // Check 6: requiresContext
  320. if (this.behavior.requiresContext) {
  321. // Check if agent loaded context files
  322. const readTools = this.getReadTools(timeline);
  323. // Log all files read for analysis
  324. const filesRead = readTools.map(rt =>
  325. rt.data?.state?.input?.filePath || rt.data?.input?.filePath || rt.data?.input?.path || 'unknown'
  326. );
  327. console.log(`\n[behavior] Files Read (${filesRead.length}):`);
  328. filesRead.forEach((file, i) => {
  329. console.log(` ${i + 1}. ${file}`);
  330. });
  331. // Context file patterns - files that count as "context loading"
  332. // Matches: .opencode/agent/*.md, .opencode/context/**/*.md, docs/**/*.md, README.md, CONTRIBUTING.md
  333. const contextPatterns = [
  334. /\.opencode\/agent\/.*\.md$/i,
  335. /\.opencode\/context\/.*\.md$/i,
  336. /docs\/.*\.md$/i,
  337. /\/CONTRIBUTING\.md$/i,
  338. /\/README\.md$/i,
  339. ];
  340. const contextReads = readTools.filter(rt => {
  341. const filePath = rt.data?.state?.input?.filePath || rt.data?.input?.filePath || rt.data?.input?.path || '';
  342. return contextPatterns.some(pattern => pattern.test(filePath));
  343. });
  344. console.log(`[behavior] Context Files Read: ${contextReads.length}/${filesRead.length}`);
  345. const hasContextLoading = contextReads.length > 0;
  346. if (!hasContextLoading) {
  347. violations.push(
  348. this.createViolation(
  349. 'missing-context-loading',
  350. 'error',
  351. 'Agent did not load required context files',
  352. Date.now(),
  353. {
  354. requiresContext: true,
  355. contextLoaded: false,
  356. }
  357. )
  358. );
  359. }
  360. checks.push({
  361. name: 'requires-context',
  362. passed: hasContextLoading,
  363. weight: 100,
  364. evidence: [
  365. this.createEvidence(
  366. 'context-loading',
  367. hasContextLoading
  368. ? `Agent loaded ${contextReads.length} context file(s)`
  369. : 'Agent did not load context files',
  370. {
  371. requiresContext: true,
  372. contextLoaded: hasContextLoading,
  373. contextFiles: contextReads.map(cr => cr.data?.input?.filePath || cr.data?.input?.path),
  374. }
  375. )
  376. ]
  377. });
  378. }
  379. // Check 7: shouldDelegate
  380. if (this.behavior.shouldDelegate) {
  381. const taskCalls = this.getToolCallsByName(timeline, 'task');
  382. const hasDelegation = taskCalls.length > 0;
  383. if (!hasDelegation) {
  384. violations.push(
  385. this.createViolation(
  386. 'missing-delegation',
  387. 'warning',
  388. 'Agent should have delegated to a subagent',
  389. Date.now(),
  390. {
  391. shouldDelegate: true,
  392. delegated: false,
  393. }
  394. )
  395. );
  396. }
  397. checks.push({
  398. name: 'should-delegate',
  399. passed: hasDelegation,
  400. weight: 75,
  401. evidence: [
  402. this.createEvidence(
  403. 'delegation',
  404. hasDelegation
  405. ? `Agent delegated to ${taskCalls.length} subagent(s)`
  406. : 'Agent did not delegate to subagents',
  407. {
  408. shouldDelegate: true,
  409. delegated: hasDelegation,
  410. delegationCount: taskCalls.length,
  411. }
  412. )
  413. ]
  414. });
  415. }
  416. // Check 8: expectedResponse (validate response content)
  417. if (this.behavior.expectedResponse) {
  418. const assistantMessages = timeline.filter(
  419. e => e.type === 'message' && e.data?.role === 'assistant' && e.data?.text
  420. );
  421. // Combine all assistant messages into one text for validation
  422. const fullResponse = assistantMessages
  423. .map(m => m.data?.text || '')
  424. .join('\n');
  425. // Check contains
  426. if (this.behavior.expectedResponse.contains && this.behavior.expectedResponse.contains.length > 0) {
  427. const missingStrings: string[] = [];
  428. for (const expectedString of this.behavior.expectedResponse.contains) {
  429. if (!fullResponse.includes(expectedString)) {
  430. missingStrings.push(expectedString);
  431. }
  432. }
  433. if (missingStrings.length > 0) {
  434. violations.push(
  435. this.createViolation(
  436. 'missing-expected-content',
  437. 'error',
  438. `Response missing expected content: ${missingStrings.join(', ')}`,
  439. Date.now(),
  440. {
  441. missingStrings,
  442. expectedStrings: this.behavior.expectedResponse.contains,
  443. }
  444. )
  445. );
  446. }
  447. checks.push({
  448. name: 'expected-response-contains',
  449. passed: missingStrings.length === 0,
  450. weight: 100,
  451. evidence: [
  452. this.createEvidence(
  453. 'response-content',
  454. missingStrings.length === 0
  455. ? `Response contains all ${this.behavior.expectedResponse.contains.length} expected strings`
  456. : `Response missing ${missingStrings.length} expected strings`,
  457. {
  458. expectedCount: this.behavior.expectedResponse.contains.length,
  459. foundCount: this.behavior.expectedResponse.contains.length - missingStrings.length,
  460. missingStrings,
  461. }
  462. )
  463. ]
  464. });
  465. }
  466. // Check notContains
  467. if (this.behavior.expectedResponse.notContains && this.behavior.expectedResponse.notContains.length > 0) {
  468. const foundForbiddenStrings: string[] = [];
  469. for (const forbiddenString of this.behavior.expectedResponse.notContains) {
  470. if (fullResponse.includes(forbiddenString)) {
  471. foundForbiddenStrings.push(forbiddenString);
  472. }
  473. }
  474. if (foundForbiddenStrings.length > 0) {
  475. violations.push(
  476. this.createViolation(
  477. 'forbidden-content-found',
  478. 'error',
  479. `Response contains forbidden content: ${foundForbiddenStrings.join(', ')}`,
  480. Date.now(),
  481. {
  482. foundForbiddenStrings,
  483. forbiddenStrings: this.behavior.expectedResponse.notContains,
  484. }
  485. )
  486. );
  487. }
  488. checks.push({
  489. name: 'expected-response-not-contains',
  490. passed: foundForbiddenStrings.length === 0,
  491. weight: 100,
  492. evidence: [
  493. this.createEvidence(
  494. 'response-content-forbidden',
  495. foundForbiddenStrings.length === 0
  496. ? `Response does not contain any of ${this.behavior.expectedResponse.notContains.length} forbidden strings`
  497. : `Response contains ${foundForbiddenStrings.length} forbidden strings`,
  498. {
  499. forbiddenCount: this.behavior.expectedResponse.notContains.length,
  500. foundCount: foundForbiddenStrings.length,
  501. foundForbiddenStrings,
  502. }
  503. )
  504. ]
  505. });
  506. }
  507. }
  508. // Add summary evidence
  509. evidence.push(
  510. this.createEvidence(
  511. 'behavior-summary',
  512. `Behavior validation: ${checks.filter(c => c.passed).length}/${checks.length} checks passed`,
  513. {
  514. totalChecks: checks.length,
  515. passedChecks: checks.filter(c => c.passed).length,
  516. failedChecks: checks.filter(c => !c.passed).length,
  517. toolsUsed: uniqueTools,
  518. toolCallCount: toolCalls.length,
  519. }
  520. )
  521. );
  522. // Print summary
  523. console.log(`\nBehavior Validation Summary:`);
  524. console.log(` Checks Passed: ${checks.filter(c => c.passed).length}/${checks.length}`);
  525. // Show which checks passed/failed with reasons
  526. if (checks.length > 0) {
  527. console.log(`\nCheck Details:`);
  528. checks.forEach((check, i) => {
  529. const icon = check.passed ? '✓' : '✗';
  530. const status = check.passed ? 'PASS' : 'FAIL';
  531. console.log(` ${icon} ${check.name}: ${status}`);
  532. // Show reason/evidence
  533. if (check.evidence && check.evidence.length > 0) {
  534. check.evidence.forEach(ev => {
  535. if (ev.description) {
  536. console.log(` → ${ev.description}`);
  537. }
  538. // Show key data points
  539. if (ev.data) {
  540. if (ev.data.expected !== undefined && ev.data.actual !== undefined) {
  541. console.log(` → Expected: ${JSON.stringify(ev.data.expected)}, Got: ${JSON.stringify(ev.data.actual)}`);
  542. } else if (ev.data.toolsUsed !== undefined) {
  543. console.log(` → Tools used: ${ev.data.toolsUsed.length > 0 ? ev.data.toolsUsed.join(', ') : 'none'}`);
  544. } else if (ev.data.count !== undefined) {
  545. console.log(` → Count: ${ev.data.count}`);
  546. }
  547. }
  548. });
  549. }
  550. });
  551. }
  552. console.log(`\n Violations: ${violations.length}`);
  553. if (violations.length > 0) {
  554. console.log(`\nViolations Detected:`);
  555. violations.forEach((v, i) => {
  556. console.log(` ${i + 1}. [${v.severity}] ${v.type}: ${v.message}`);
  557. });
  558. }
  559. console.log(`${'='.repeat(60)}\n`);
  560. return this.buildResult(this.name, checks, violations, evidence, {
  561. behavior: this.behavior,
  562. toolsUsed: uniqueTools,
  563. toolCallCount: toolCalls.length,
  564. });
  565. }
  566. }