-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[STG-2515] feat(evals): vercel_ai_sdk bare-loop harness adapter #2338
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
Open
shrey150
wants to merge
4
commits into
shrey/evals-bareloop-harnesses
Choose a base branch
from
shrey/evals-harness-vercel-ai-sdk
base: shrey/evals-bareloop-harnesses
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ae8f36e
feat(evals): vercel_ai_sdk bare-loop harness adapter
shrey150 d121c70
fix(evals): make step-cap truncation detectable in the vercel_ai_sdk …
shrey150 b5d3a70
docs(evals): generalize vercel_ai_sdk runner docblock
shrey150 1fe1fb5
test(evals): assert vercel_ai_sdk is classified executable
shrey150 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| /** | ||
| * vercelAiSdkRunner — bare-loop harness via the Vercel AI SDK (`ai` package). | ||
| * | ||
| * The loop is `generateText` with `stopWhen: stepCountIs(N)` and a single | ||
| * `browse` tool — the AI SDK's own multi-step tool loop IS the harness, with | ||
| * zero additional scaffolding, mirroring the default way a JS developer | ||
| * wires a tool loop with this SDK. | ||
| * | ||
| * Testability: pass `generateTextFn` to drive the loop with a mock. The mock | ||
| * receives the fully-built options (system, prompt, tools, stopWhen) and can | ||
| * invoke `options.tools.browse.execute(...)` to exercise tool wiring. | ||
| */ | ||
| import type { AvailableModel } from "@browserbasehq/stagehand"; | ||
| import { getAISDKLanguageModel } from "@browserbasehq/stagehand"; | ||
| import { EvalsError } from "../errors.js"; | ||
| import type { EvalLogger } from "../logger.js"; | ||
| import type { TaskResult } from "./types.js"; | ||
| import type { ExternalHarnessTaskPlan } from "./externalHarnessPlan.js"; | ||
| import type { PreparedExternalHarnessAdapter } from "./externalHarnessToolAdapter.js"; | ||
| import { readBareLoopMaxSteps } from "./bareLoopConfig.js"; | ||
| import { | ||
| BROWSE_TOOL_DESCRIPTION, | ||
| BROWSE_TOOL_NAME, | ||
| buildBareLoopUserPrompt, | ||
| createBareLoopToolRecorder, | ||
| finalizeBareLoopResult, | ||
| stringifyLoopError, | ||
| } from "./bareLoopRunner.js"; | ||
| import type { ExternalHarnessVerifierConfig } from "./verifierAdapter.js"; | ||
|
|
||
| const HARNESS = "vercel_ai_sdk"; | ||
| const MAX_STEPS_ENV = "EVAL_VERCEL_AI_SDK_MAX_STEPS"; | ||
|
|
||
| export interface VercelAiSdkGenerateTextResult { | ||
| text: string; | ||
| steps?: unknown[]; | ||
| totalUsage?: { | ||
| inputTokens?: number; | ||
| outputTokens?: number; | ||
| cachedInputTokens?: number; | ||
| reasoningTokens?: number; | ||
| }; | ||
| finishReason?: string; | ||
| } | ||
|
|
||
| export type VercelAiSdkGenerateTextFn = ( | ||
| options: Record<string, unknown>, | ||
| ) => Promise<VercelAiSdkGenerateTextResult>; | ||
|
|
||
| export interface VercelAiSdkRunnerInput { | ||
| plan: ExternalHarnessTaskPlan; | ||
| model: AvailableModel; | ||
| logger: EvalLogger; | ||
| toolAdapter: PreparedExternalHarnessAdapter; | ||
| signal?: AbortSignal; | ||
| /** Injectable for unit tests; defaults to the real `ai` generateText. */ | ||
| generateTextFn?: VercelAiSdkGenerateTextFn; | ||
| verifier?: ExternalHarnessVerifierConfig; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve "provider/model" to an AI SDK LanguageModel via stagehand's own | ||
| * provider map — the exact resolution the `-m` flag already uses for the | ||
| * stagehand harness, so model names mean the same thing on every harness. | ||
| */ | ||
| export function resolveVercelAiSdkModel(model: AvailableModel): unknown { | ||
| if (!model.includes("/")) { | ||
| throw new EvalsError( | ||
| `vercel_ai_sdk harness requires a provider-prefixed model (e.g. anthropic/claude-sonnet-4-6); received "${model}".`, | ||
| ); | ||
| } | ||
| const slash = model.indexOf("/"); | ||
| return getAISDKLanguageModel(model.slice(0, slash), model.slice(slash + 1)); | ||
| } | ||
|
|
||
| export async function runVercelAiSdkAgent( | ||
| input: VercelAiSdkRunnerInput, | ||
| ): Promise<TaskResult> { | ||
| const maxSteps = readBareLoopMaxSteps(MAX_STEPS_ENV); | ||
| const recorder = createBareLoopToolRecorder( | ||
| input.toolAdapter, | ||
| input.logger, | ||
| HARNESS, | ||
| ); | ||
|
|
||
| let finalText = ""; | ||
| let stepsUsed = 0; | ||
| let usage: VercelAiSdkGenerateTextResult["totalUsage"]; | ||
| let loopError: unknown; | ||
| let cappedOut = false; | ||
|
|
||
| try { | ||
| const ai = await import("ai"); | ||
| const { z } = await import("zod"); | ||
| const generateTextFn = | ||
| input.generateTextFn ?? | ||
| (ai.generateText as unknown as VercelAiSdkGenerateTextFn); | ||
|
|
||
| const result = await generateTextFn({ | ||
| model: | ||
| input.generateTextFn === undefined | ||
| ? resolveVercelAiSdkModel(input.model) | ||
| : input.model, | ||
| system: input.toolAdapter.systemPromptAddendum, | ||
| prompt: buildBareLoopUserPrompt(input.plan), | ||
| stopWhen: ai.stepCountIs(maxSteps), | ||
| ...(input.signal && { abortSignal: input.signal }), | ||
| tools: { | ||
| [BROWSE_TOOL_NAME]: ai.tool({ | ||
| description: BROWSE_TOOL_DESCRIPTION, | ||
| inputSchema: z.object({ | ||
| args: z | ||
| .string() | ||
| .describe( | ||
| 'Everything after "browse", e.g. "open https://example.com".', | ||
| ), | ||
| }), | ||
| execute: async ({ args }: { args: string }) => recorder.execute(args), | ||
| }), | ||
| }, | ||
| }); | ||
|
|
||
| finalText = result.text ?? ""; | ||
| stepsUsed = result.steps?.length ?? 0; | ||
| usage = result.totalUsage; | ||
| // stopWhen: stepCountIs(maxSteps) ends the loop silently -- generateText | ||
| // returns normally either way, so a clean stop and a cap-truncated one are | ||
| // otherwise indistinguishable. finishReason on the last step is "stop" | ||
| // when the model was actually done, or "tool-calls" when it still wanted | ||
| // to call a tool but the step cap cut it off first. Mirrors anthropic_sdk's | ||
| // stopReason shape so all bare-loop harnesses report the cap the same way. | ||
| cappedOut = stepsUsed >= maxSteps && result.finishReason === "tool-calls"; | ||
| } catch (error) { | ||
| loopError = error; | ||
| input.logger.warn({ | ||
| category: HARNESS, | ||
| message: `vercel_ai_sdk loop stopped before a normal result: ${stringifyLoopError(error)}`, | ||
| level: 0, | ||
| }); | ||
| } | ||
|
|
||
| return finalizeBareLoopResult({ | ||
| harness: HARNESS, | ||
| toolCalls: recorder.calls, | ||
| finalText, | ||
| status: loopError ? "error" : "complete", | ||
| stopReason: loopError | ||
|
shrey150 marked this conversation as resolved.
|
||
| ? stringifyLoopError(loopError) | ||
| : cappedOut | ||
| ? `step cap reached (${maxSteps})` | ||
| : undefined, | ||
| usage: { | ||
| input_tokens: usage?.inputTokens ?? 0, | ||
| output_tokens: usage?.outputTokens ?? 0, | ||
| ...(usage?.cachedInputTokens !== undefined && { | ||
| cached_input_tokens: usage.cachedInputTokens, | ||
| }), | ||
| ...(usage?.reasoningTokens !== undefined && { | ||
| reasoning_tokens: usage.reasoningTokens, | ||
| }), | ||
| }, | ||
| stepsUsed, | ||
| maxSteps, | ||
| logger: input.logger, | ||
| verifier: input.verifier, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
166 changes: 166 additions & 0 deletions
166
packages/evals/tests/framework/vercelAiSdkRunner.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| import fsp from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import { afterEach, describe, expect, it } from "vitest"; | ||
| import type { AvailableModel } from "@browserbasehq/stagehand"; | ||
| import { EvalLogger } from "../../logger.js"; | ||
| import type { ExternalHarnessTaskPlan } from "../../framework/externalHarnessPlan.js"; | ||
| import type { PreparedExternalHarnessAdapter } from "../../framework/externalHarnessToolAdapter.js"; | ||
| import { BARE_LOOP_DEFAULT_SYSTEM_PROMPT } from "../../framework/externalHarnessToolAdapter.js"; | ||
| import { | ||
| resolveVercelAiSdkModel, | ||
| runVercelAiSdkAgent, | ||
| } from "../../framework/vercelAiSdkRunner.js"; | ||
|
|
||
| const plan: ExternalHarnessTaskPlan = { | ||
| dataset: "webtailbench", | ||
| taskId: "wtb-1", | ||
| startUrl: "https://example.com", | ||
| instruction: "Find the checkout button", | ||
| }; | ||
|
|
||
| let tempDir: string | undefined; | ||
|
|
||
| afterEach(async () => { | ||
| if (tempDir) { | ||
| await fsp.rm(tempDir, { recursive: true, force: true }); | ||
| tempDir = undefined; | ||
| } | ||
| delete process.env.EVAL_VERCEL_AI_SDK_MAX_STEPS; | ||
| }); | ||
|
|
||
| async function makeAdapter(): Promise<PreparedExternalHarnessAdapter> { | ||
| tempDir = await fsp.mkdtemp( | ||
| path.join(os.tmpdir(), "stagehand-evals-vercel-test-"), | ||
| ); | ||
| const bin = path.join(tempDir, "browse"); | ||
| await fsp.writeFile(bin, '#!/usr/bin/env bash\necho "browse-output:$@"\n', { | ||
| mode: 0o755, | ||
| }); | ||
| return { | ||
| cwd: tempDir, | ||
| env: { ...process.env } as Record<string, string>, | ||
| browseBinPath: bin, | ||
| skillMode: "none", | ||
| systemPromptAddendum: BARE_LOOP_DEFAULT_SYSTEM_PROMPT, | ||
| metadata: { toolCommand: "browse", browseCliEntrypoint: bin }, | ||
| cleanup: async () => {}, | ||
| }; | ||
| } | ||
|
|
||
| describe("vercel_ai_sdk runner", () => { | ||
| it("requires a provider-prefixed model", () => { | ||
| expect(() => | ||
| resolveVercelAiSdkModel("claude-sonnet-4-6" as AvailableModel), | ||
| ).toThrow(/provider-prefixed/); | ||
| }); | ||
|
|
||
| it("drives the loop through generateText with the bare system prompt and records tool calls", async () => { | ||
| const adapter = await makeAdapter(); | ||
| process.env.EVAL_VERCEL_AI_SDK_MAX_STEPS = "7"; | ||
| let captured: Record<string, unknown> | undefined; | ||
|
|
||
| const result = await runVercelAiSdkAgent({ | ||
| plan, | ||
| model: "anthropic/claude-sonnet-4-6" as AvailableModel, | ||
| logger: new EvalLogger(false), | ||
| toolAdapter: adapter, | ||
| generateTextFn: async (options) => { | ||
| captured = options; | ||
| const tools = options.tools as Record< | ||
| string, | ||
| { execute: (input: { args: string }) => Promise<string> } | ||
| >; | ||
| const first = await tools.browse.execute({ args: "--help" }); | ||
| expect(first).toContain("browse-output:--help"); | ||
| await tools.browse.execute({ args: "open https://example.com" }); | ||
| return { | ||
| text: 'done\nEVAL_RESULT: {"success":true,"summary":"found it","finalAnswer":"checkout"}', | ||
| steps: [{}, {}, {}], | ||
| totalUsage: { inputTokens: 120, outputTokens: 30 }, | ||
| }; | ||
| }, | ||
| }); | ||
|
|
||
| expect(captured?.system).toBe(BARE_LOOP_DEFAULT_SYSTEM_PROMPT); | ||
| expect(String(captured?.prompt)).toContain("Find the checkout button"); | ||
| expect(String(captured?.prompt)).toContain( | ||
| "Start URL: https://example.com", | ||
| ); | ||
| expect(result._success).toBe(true); | ||
| expect(result.finalAnswer).toBe("checkout"); | ||
| expect(result.vercelAiSdkStatus).toBe("completed"); | ||
| const metrics = result.metrics as Record<string, { value: number }>; | ||
| expect(metrics.vercel_ai_sdk_tool_calls.value).toBe(2); | ||
| expect(metrics.vercel_ai_sdk_steps.value).toBe(3); | ||
| expect(metrics.vercel_ai_sdk_max_steps.value).toBe(7); | ||
| expect(metrics.vercel_ai_sdk_input_tokens.value).toBe(120); | ||
| expect(metrics.vercel_ai_sdk_output_tokens.value).toBe(30); | ||
| }); | ||
|
|
||
| it("stops at the step cap and reports it as the stop reason", async () => { | ||
| const adapter = await makeAdapter(); | ||
| process.env.EVAL_VERCEL_AI_SDK_MAX_STEPS = "2"; | ||
|
|
||
| const result = await runVercelAiSdkAgent({ | ||
| plan, | ||
| model: "anthropic/claude-sonnet-4-6" as AvailableModel, | ||
| logger: new EvalLogger(false), | ||
| toolAdapter: adapter, | ||
| generateTextFn: async () => ({ | ||
| // The model was still mid tool-call when stepCountIs(2) cut the loop | ||
| // off -- generateText returns normally either way, so finishReason is | ||
| // the only signal that distinguishes this from a clean stop. | ||
| text: "", | ||
| steps: [{}, {}], | ||
| finishReason: "tool-calls", | ||
| totalUsage: { inputTokens: 10, outputTokens: 5 }, | ||
| }), | ||
| }); | ||
|
|
||
| expect(result._success).toBe(false); | ||
| expect(result.vercelAiSdkStatus).toBe("completed"); | ||
| expect(result.vercelAiSdkStopReason).toContain("step cap reached (2)"); | ||
| const metrics = result.metrics as Record<string, { value: number }>; | ||
| expect(metrics.vercel_ai_sdk_steps.value).toBe(2); | ||
| expect(metrics.vercel_ai_sdk_max_steps.value).toBe(2); | ||
| }); | ||
|
|
||
| it("does not report a step cap on a clean stop even at the max step count", async () => { | ||
| const adapter = await makeAdapter(); | ||
| process.env.EVAL_VERCEL_AI_SDK_MAX_STEPS = "2"; | ||
|
|
||
| const result = await runVercelAiSdkAgent({ | ||
| plan, | ||
| model: "anthropic/claude-sonnet-4-6" as AvailableModel, | ||
| logger: new EvalLogger(false), | ||
| toolAdapter: adapter, | ||
| generateTextFn: async () => ({ | ||
| text: 'EVAL_RESULT: {"success":true,"summary":"done","finalAnswer":"checkout"}', | ||
| steps: [{}, {}], | ||
| finishReason: "stop", | ||
| totalUsage: { inputTokens: 10, outputTokens: 5 }, | ||
| }), | ||
| }); | ||
|
|
||
| expect(result._success).toBe(true); | ||
| expect(result.vercelAiSdkStopReason).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns a failed task result instead of throwing on loop errors", async () => { | ||
| const adapter = await makeAdapter(); | ||
| const result = await runVercelAiSdkAgent({ | ||
| plan, | ||
| model: "anthropic/claude-sonnet-4-6" as AvailableModel, | ||
| logger: new EvalLogger(false), | ||
| toolAdapter: adapter, | ||
| generateTextFn: async () => { | ||
| throw new Error("provider exploded"); | ||
| }, | ||
| }); | ||
|
|
||
| expect(result._success).toBe(false); | ||
| expect(result.vercelAiSdkStatus).toBe("error"); | ||
| expect(result.vercelAiSdkStopReason).toContain("provider exploded"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.