-
Notifications
You must be signed in to change notification settings - Fork 5
Fix MCP live eval after steering removal #363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { describe, expect, it } from 'vite-plus/test'; | ||
| import type { ToolResult } from './client.js'; | ||
| import { scoreResult } from './quality.js'; | ||
|
|
||
| function resultWithJson(json: unknown): ToolResult { | ||
| const text = JSON.stringify(json); | ||
| return { | ||
| http: 200, | ||
| isError: false, | ||
| latencyMs: 10, | ||
| bytes: text.length, | ||
| blocks: [{ index: 0, text, json }], | ||
| payload: json, | ||
| errorMessage: undefined, | ||
| rawText: text, | ||
| }; | ||
| } | ||
|
|
||
| describe('scoreResult', () => { | ||
| it('accepts a response without removed steering metadata', () => { | ||
| const score = scoreResult(resultWithJson({ items: [] }), {}); | ||
|
|
||
| expect(score.contractPass).toBe(true); | ||
| expect( | ||
| score.checks.find( | ||
| (check) => check.name === 'removed steering metadata is absent', | ||
| )?.pass, | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it('rejects removed steering metadata at any JSON depth', () => { | ||
| const score = scoreResult( | ||
| resultWithJson({ | ||
| nested: { | ||
| _agent_steering: true, | ||
| presentation_guidance: 'Present this result', | ||
| suggested_follow_ups: ['Check another container'], | ||
| suggested_tools: ['get_container'], | ||
| }, | ||
| }), | ||
| {}, | ||
| ); | ||
| const check = score.checks.find( | ||
| (candidate) => candidate.name === 'removed steering metadata is absent', | ||
| ); | ||
|
|
||
| expect(score.contractPass).toBe(false); | ||
| expect(check?.pass).toBe(false); | ||
| expect(check?.detail).toBe( | ||
| '_agent_steering, presentation_guidance, suggested_follow_ups, suggested_tools', | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,10 +3,9 @@ | |
| * | ||
| * These scorers do NOT use an LLM. They evaluate the objective contract of a | ||
| * tool response: transport success, error semantics, payload shape, required | ||
| * fields, latency budget, and the presence/usefulness of the `_agent_steering` | ||
| * guidance block that this server attaches to every tool. Subjective | ||
| * "is this a good answer" judging (LLM-as-judge over an agent transcript) is a | ||
| * separate, optional layer — see eval/README.md. | ||
| * fields, latency budget, and the absence of removed runtime steering metadata. | ||
| * Subjective "is this a good answer" judging (LLM-as-judge over an agent | ||
| * transcript) is a separate, optional layer — see eval/README.md. | ||
| */ | ||
|
|
||
| import { isRecord, type ToolResult } from './client.js'; | ||
|
|
@@ -44,11 +43,15 @@ export interface QualitySpec { | |
| predicates?: Predicate[]; | ||
| /** Negative test: expect an MCP tool error instead of a payload. */ | ||
| expectError?: boolean; | ||
| /** Require an `_agent_steering` block that suggests follow-ups. */ | ||
| requireSteering?: boolean; | ||
| } | ||
|
|
||
| const DEFAULT_LATENCY_BUDGET_MS = 8000; | ||
| const REMOVED_STEERING_FIELDS = new Set([ | ||
| '_agent_steering', | ||
| 'presentation_guidance', | ||
| 'suggested_follow_ups', | ||
| 'suggested_tools', | ||
| ]); | ||
|
|
||
| export function scoreResult( | ||
| result: ToolResult, | ||
|
|
@@ -80,6 +83,14 @@ export function scoreResult( | |
| add('not a tool error', !result.isError, result.errorMessage); | ||
| add('primary payload is JSON', result.payload !== undefined); | ||
| add('non-empty response', result.bytes > 0, `${result.bytes}b`); | ||
| const removedFields = findRemovedSteeringFields( | ||
| result.blocks.map((block) => block.json), | ||
| ); | ||
|
Comment on lines
+86
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The check only walks JSON parsed from text blocks, while MCP tool responses also expose Useful? React with 👍 / 👎. |
||
| add( | ||
| 'removed steering metadata is absent', | ||
| removedFields.length === 0, | ||
| removedFields.length > 0 ? removedFields.join(', ') : undefined, | ||
| ); | ||
|
|
||
| for (const key of spec.requiredKeys ?? []) { | ||
| add( | ||
|
|
@@ -107,11 +118,6 @@ export function scoreResult( | |
| } | ||
| } | ||
|
|
||
| if (spec.requireSteering) { | ||
| add('has _agent_steering block', result.steering !== undefined); | ||
| add('steering suggests follow-ups', steeringHasFollowUps(result.steering)); | ||
| } | ||
|
|
||
| // Latency is a soft signal: recorded and scored, but a slow response is not | ||
| // a contract violation, so it never fails the suite on its own. | ||
| add( | ||
|
|
@@ -131,16 +137,24 @@ export function scoreResult( | |
| }; | ||
| } | ||
|
|
||
| function steeringHasFollowUps( | ||
| steering: Record<string, unknown> | undefined, | ||
| ): boolean { | ||
| if (!steering) return false; | ||
| const followUps = steering.suggested_follow_ups; | ||
| const tools = steering.suggested_tools; | ||
| return ( | ||
| (Array.isArray(followUps) && followUps.length > 0) || | ||
| (Array.isArray(tools) && tools.length > 0) | ||
| ); | ||
| function findRemovedSteeringFields(values: unknown[]): string[] { | ||
| const found = new Set<string>(); | ||
|
|
||
| function visit(value: unknown): void { | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) visit(item); | ||
| return; | ||
| } | ||
| if (!isRecord(value)) return; | ||
|
|
||
| for (const [key, nestedValue] of Object.entries(value)) { | ||
| if (REMOVED_STEERING_FIELDS.has(key)) found.add(key); | ||
| visit(nestedValue); | ||
| } | ||
| } | ||
|
|
||
| for (const value of values) visit(value); | ||
| return [...found].sort(); | ||
| } | ||
|
|
||
| // ---- small typed helpers for writing predicates against `unknown` payloads ---- | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_response_contractin the forbidden keysThe steering-removal contract also forbids
_response_contract:stripResponseSteeringexplicitly removes it and the MCP transport tests reject its presence. Because this set omits that key, a response such as{"_response_contract": {}}passes the new absence check even though it restores the removed runtime contract wrapper. Add_response_contractto the forbidden-field check and its focused test.Useful? React with 👍 / 👎.