diff --git a/apps/dev-playground/server/agents/query/evals/judge.eval.ts b/apps/dev-playground/server/agents/query/evals/judge.eval.ts new file mode 100644 index 000000000..039045bdd --- /dev/null +++ b/apps/dev-playground/server/agents/query/evals/judge.eval.ts @@ -0,0 +1,25 @@ +import { defineEval } from "@databricks/appkit/beta"; + +/** + * LLM-as-judge eval: scores the helper agent's weather answer with a judge + * model (via autoevals → a Databricks serving endpoint). + * + * Requires a judge model: + * appkit agent eval query --root apps/dev-playground --url http://localhost:8001 \ + * --judge-model databricks-claude-sonnet-4-5 + * (or set APPKIT_JUDGE_MODEL). Without it, t.judge.* errors with a clear message. + */ +export default defineEval({ + description: "Helper weather answer is relevant (LLM judge)", + agent: "helper", + async test(t) { + await t.send("What's the weather in Brooklyn?"); + t.succeeded(); + // closedQA needs no ground truth — it judges the reply against a question. + ( + await t.judge.closedQA( + "Does the response describe weather conditions for Brooklyn?", + ) + ).atLeast(0.5); + }, +}); diff --git a/apps/dev-playground/server/agents/query/evals/smoke.eval.ts b/apps/dev-playground/server/agents/query/evals/smoke.eval.ts new file mode 100644 index 000000000..de3f2d4d4 --- /dev/null +++ b/apps/dev-playground/server/agents/query/evals/smoke.eval.ts @@ -0,0 +1,14 @@ +import { defineEval } from "@databricks/appkit/beta"; + +/** + * Example eval. The agent defaults to this directory's name (`query`). + * Run with: + * pnpm exec appkit agent eval query --root apps/dev-playground --url http://localhost:8000 + */ +export default defineEval({ + description: "Query dispatcher responds to a greeting", + async test(t) { + await t.send("Hi there!"); + t.succeeded(); // gate: the turn completed + }, +}); diff --git a/apps/dev-playground/server/agents/query/evals/sum.eval.ts b/apps/dev-playground/server/agents/query/evals/sum.eval.ts new file mode 100644 index 000000000..9bc905e6d --- /dev/null +++ b/apps/dev-playground/server/agents/query/evals/sum.eval.ts @@ -0,0 +1,13 @@ +import { defineEval, includes } from "@databricks/appkit/beta"; + +export default defineEval({ + description: "Helper agent smoke test", + // Target the default code-defined agent. Drop this to use the `query` agent + // (the parent directory name). + agent: "helper", + async test(t) { + await t.send("What is 2 + 2?"); + t.succeeded(); // gate: the turn completed + t.check(t.reply, includes("4")).soft(); // tracked metric, won't fail the gate + }, +}); diff --git a/apps/dev-playground/server/agents/query/evals/tool-call.eval.ts b/apps/dev-playground/server/agents/query/evals/tool-call.eval.ts new file mode 100644 index 000000000..47221330a --- /dev/null +++ b/apps/dev-playground/server/agents/query/evals/tool-call.eval.ts @@ -0,0 +1,16 @@ +import { defineEval } from "@databricks/appkit/beta"; + +/** + * Tool-call eval: the default `helper` agent should call its `get_weather` + * tool when asked about the weather. (Targets `helper` explicitly — the parent + * directory only determines discovery, not which agent runs.) + */ +export default defineEval({ + description: "Helper agent calls the get_weather tool", + agent: "helper", + async test(t) { + await t.send("What's the weather in Brooklyn?"); + t.succeeded(); + t.calledTool("get_weather"); + }, +}); diff --git a/docs/docs/api/appkit/Class.MlflowClient.md b/docs/docs/api/appkit/Class.MlflowClient.md new file mode 100644 index 000000000..c0e81c0f1 --- /dev/null +++ b/docs/docs/api/appkit/Class.MlflowClient.md @@ -0,0 +1,110 @@ +# Class: MlflowClient + +A thin client over the Databricks workspace REST API, owning the host + bearer +token so callers (eval-run creation, assessment writes, the judge's serving +endpoint) don't each re-derive URLs or re-attach auth. The host is normalized +once at construction. + +## Constructors + +### Constructor + +```ts +new MlflowClient(host: string, token: string): MlflowClient; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `host` | `string` | +| `token` | `string` | + +#### Returns + +`MlflowClient` + +## Properties + +### baseUrl + +```ts +readonly baseUrl: string; +``` + +Normalized workspace base URL (scheme guaranteed, no trailing slash). + +## Methods + +### post() + +```ts +post(path: string, body: unknown): Promise; +``` + +POST JSON to an MLflow REST endpoint. Returns the parsed JSON body, or +throws with the status + response text so callers can surface a precise +error. Use for calls whose failure should abort (e.g. `runs/create`). + +The thrown message embeds up to 500 chars of the upstream response body to +aid debugging. That is fine for the dev-facing eval CLI, but do NOT relay +it into an end-user HTTP response if this client is reused in a request +handler — the body can carry workspace-internal detail. + +#### Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `T` | `unknown` | + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `path` | `string` | +| `body` | `unknown` | + +#### Returns + +`Promise`\<`T`\> + +*** + +### postResult() + +```ts +postResult(path: string, body: unknown): Promise; +``` + +POST JSON without throwing: returns `{ ok, status, error }` so best-effort +writes (e.g. per-trace assessments) can be collected and reported without +aborting the run. + +`error` embeds up to 500 chars of the upstream body — same caveat as +[post](#post): fine to log for the dev CLI, don't relay it to end users. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `path` | `string` | +| `body` | `unknown` | + +#### Returns + +`Promise`\<[`PostResult`](Interface.PostResult.md)\> + +*** + +### servingEndpointsUrl() + +```ts +servingEndpointsUrl(): string; +``` + +OpenAI-compatible base URL for Databricks Model Serving, used as the judge's +`OPENAI_BASE_URL`. Same workspace host + token as the MLflow REST calls. + +#### Returns + +`string` diff --git a/docs/docs/api/appkit/Function.buildAssessments.md b/docs/docs/api/appkit/Function.buildAssessments.md new file mode 100644 index 000000000..6008e86db --- /dev/null +++ b/docs/docs/api/appkit/Function.buildAssessments.md @@ -0,0 +1,15 @@ +# Function: buildAssessments() + +```ts +function buildAssessments(result: EvalResult): Assessment[]; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `result` | [`EvalResult`](Interface.EvalResult.md) | + +## Returns + +[`Assessment`](Interface.Assessment.md)[] diff --git a/docs/docs/api/appkit/Function.configureJudge.md b/docs/docs/api/appkit/Function.configureJudge.md new file mode 100644 index 000000000..140042b14 --- /dev/null +++ b/docs/docs/api/appkit/Function.configureJudge.md @@ -0,0 +1,19 @@ +# Function: configureJudge() + +```ts +function configureJudge(config: JudgeConfig): Promise; +``` + +Configure the judge once. Sets the OpenAI-compatible client env autoevals +reads and the default judge model. No-op-safe: on failure, judging stays +disabled and [isJudgeConfigured](Function.isJudgeConfigured.md) returns false. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `config` | [`JudgeConfig`](Interface.JudgeConfig.md) | + +## Returns + +`Promise`\<`void`\> diff --git a/docs/docs/api/appkit/Function.createHttpDriver.md b/docs/docs/api/appkit/Function.createHttpDriver.md new file mode 100644 index 000000000..b373be10b --- /dev/null +++ b/docs/docs/api/appkit/Function.createHttpDriver.md @@ -0,0 +1,20 @@ +# Function: createHttpDriver() + +```ts +function createHttpDriver(options: HttpDriverOptions): EvalDriver; +``` + +Drives an agent by POSTing to a running app's chat endpoint and parsing the +SSE response. Keeps the thread id across `send`s so multi-turn evals share a +conversation. Agent/stream errors surface as `succeeded: false` rather than +throwing, so `t.succeeded()` can assert on them. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `options` | [`HttpDriverOptions`](Interface.HttpDriverOptions.md) | + +## Returns + +[`EvalDriver`](Interface.EvalDriver.md) diff --git a/docs/docs/api/appkit/Function.defineEval.md b/docs/docs/api/appkit/Function.defineEval.md new file mode 100644 index 000000000..d69656eeb --- /dev/null +++ b/docs/docs/api/appkit/Function.defineEval.md @@ -0,0 +1,34 @@ +# Function: defineEval() + +```ts +function defineEval(def: EvalDefinition): EvalDefinition; +``` + +Define an agent eval. Default-export the result from a +`server/agents//evals/*.eval.ts` file. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `def` | [`EvalDefinition`](Interface.EvalDefinition.md) | + +## Returns + +[`EvalDefinition`](Interface.EvalDefinition.md) + +## Example + +```ts +import { defineEval, includes } from "@databricks/appkit/beta"; + +export default defineEval({ + description: "Weather agent basic coverage", + async test(t) { + await t.send("What's the weather in Brooklyn?"); + t.succeeded(); + t.calledTool("get_weather"); + t.check(t.reply, includes("Sunny")); + }, +}); +``` diff --git a/docs/docs/api/appkit/Function.discoverEvalFiles.md b/docs/docs/api/appkit/Function.discoverEvalFiles.md new file mode 100644 index 000000000..57347de8d --- /dev/null +++ b/docs/docs/api/appkit/Function.discoverEvalFiles.md @@ -0,0 +1,20 @@ +# Function: discoverEvalFiles() + +```ts +function discoverEvalFiles(rootDir: string): DiscoveredEval[]; +``` + +Discover evals under `/server/agents//evals/` — co-located +with each agent's `agent.{md,ts}` (same folder-per-agent layout the agents +plugin discovers). The agent id is the folder name; the eval id is the file +path relative to that evals dir with `.eval.ts` stripped. Sorted + stable. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `rootDir` | `string` | + +## Returns + +[`DiscoveredEval`](Interface.DiscoveredEval.md)[] diff --git a/docs/docs/api/appkit/Function.equals.md b/docs/docs/api/appkit/Function.equals.md new file mode 100644 index 000000000..6d93b8377 --- /dev/null +++ b/docs/docs/api/appkit/Function.equals.md @@ -0,0 +1,17 @@ +# Function: equals() + +```ts +function equals(expected: string): Matcher; +``` + +Passes when the value equals `expected` exactly. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `expected` | `string` | + +## Returns + +[`Matcher`](TypeAlias.Matcher.md) diff --git a/docs/docs/api/appkit/Function.evalGlyph.md b/docs/docs/api/appkit/Function.evalGlyph.md new file mode 100644 index 000000000..edef1483d --- /dev/null +++ b/docs/docs/api/appkit/Function.evalGlyph.md @@ -0,0 +1,17 @@ +# Function: evalGlyph() + +```ts +function evalGlyph(result: EvalResult): string; +``` + +Status glyph for a single eval result. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `result` | [`EvalResult`](Interface.EvalResult.md) | + +## Returns + +`string` diff --git a/docs/docs/api/appkit/Function.formatEvalDetail.md b/docs/docs/api/appkit/Function.formatEvalDetail.md new file mode 100644 index 000000000..f07f83a1f --- /dev/null +++ b/docs/docs/api/appkit/Function.formatEvalDetail.md @@ -0,0 +1,17 @@ +# Function: formatEvalDetail() + +```ts +function formatEvalDetail(result: EvalResult): string[]; +``` + +Indented detail lines for a failing eval (error + failing assertions). + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `result` | [`EvalResult`](Interface.EvalResult.md) | + +## Returns + +`string`[] diff --git a/docs/docs/api/appkit/Function.formatEvalHeadline.md b/docs/docs/api/appkit/Function.formatEvalHeadline.md new file mode 100644 index 000000000..f6d22ee8d --- /dev/null +++ b/docs/docs/api/appkit/Function.formatEvalHeadline.md @@ -0,0 +1,17 @@ +# Function: formatEvalHeadline() + +```ts +function formatEvalHeadline(result: EvalResult): string; +``` + +The one-line header for a single eval result (no failure detail). + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `result` | [`EvalResult`](Interface.EvalResult.md) | + +## Returns + +`string` diff --git a/docs/docs/api/appkit/Function.formatEvalResults.md b/docs/docs/api/appkit/Function.formatEvalResults.md new file mode 100644 index 000000000..2607a52b7 --- /dev/null +++ b/docs/docs/api/appkit/Function.formatEvalResults.md @@ -0,0 +1,17 @@ +# Function: formatEvalResults() + +```ts +function formatEvalResults(results: EvalResult[]): string; +``` + +Render all results as a human-readable console report (non-streaming). + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `results` | [`EvalResult`](Interface.EvalResult.md)[] | + +## Returns + +`string` diff --git a/docs/docs/api/appkit/Function.formatSummaryLine.md b/docs/docs/api/appkit/Function.formatSummaryLine.md new file mode 100644 index 000000000..355f92958 --- /dev/null +++ b/docs/docs/api/appkit/Function.formatSummaryLine.md @@ -0,0 +1,17 @@ +# Function: formatSummaryLine() + +```ts +function formatSummaryLine(results: EvalResult[]): string; +``` + +The final PASS/FAIL summary line. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `results` | [`EvalResult`](Interface.EvalResult.md)[] | + +## Returns + +`string` diff --git a/docs/docs/api/appkit/Function.includes.md b/docs/docs/api/appkit/Function.includes.md new file mode 100644 index 000000000..3ff1ab7d5 --- /dev/null +++ b/docs/docs/api/appkit/Function.includes.md @@ -0,0 +1,17 @@ +# Function: includes() + +```ts +function includes(substring: string): Matcher; +``` + +Passes when the value contains `substring`. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `substring` | `string` | + +## Returns + +[`Matcher`](TypeAlias.Matcher.md) diff --git a/docs/docs/api/appkit/Function.isJudgeConfigured.md b/docs/docs/api/appkit/Function.isJudgeConfigured.md new file mode 100644 index 000000000..04d8f89f0 --- /dev/null +++ b/docs/docs/api/appkit/Function.isJudgeConfigured.md @@ -0,0 +1,9 @@ +# Function: isJudgeConfigured() + +```ts +function isJudgeConfigured(): boolean; +``` + +## Returns + +`boolean` diff --git a/docs/docs/api/appkit/Function.matches.md b/docs/docs/api/appkit/Function.matches.md new file mode 100644 index 000000000..6839fa5ce --- /dev/null +++ b/docs/docs/api/appkit/Function.matches.md @@ -0,0 +1,17 @@ +# Function: matches() + +```ts +function matches(pattern: RegExp): Matcher; +``` + +Passes when the value matches `pattern`. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `pattern` | `RegExp` | + +## Returns + +[`Matcher`](TypeAlias.Matcher.md) diff --git a/docs/docs/api/appkit/Function.normalizeHost.md b/docs/docs/api/appkit/Function.normalizeHost.md new file mode 100644 index 000000000..2f4606cc6 --- /dev/null +++ b/docs/docs/api/appkit/Function.normalizeHost.md @@ -0,0 +1,17 @@ +# Function: normalizeHost() + +```ts +function normalizeHost(raw: string): string; +``` + +Ensure the host has a scheme (Databricks env often lacks `https://`). + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `raw` | `string` | + +## Returns + +`string` diff --git a/docs/docs/api/appkit/Function.reportToMlflow.md b/docs/docs/api/appkit/Function.reportToMlflow.md new file mode 100644 index 000000000..888a9293a --- /dev/null +++ b/docs/docs/api/appkit/Function.reportToMlflow.md @@ -0,0 +1,23 @@ +# Function: reportToMlflow() + +```ts +function reportToMlflow( + client: MlflowClient, + results: EvalResult[], +sqlWarehouseId?: string): Promise; +``` + +Write one pass/fail assessment per eval result to the Databricks MLflow REST +API. Never throws — failures are collected so the run still reports. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `client` | [`MlflowClient`](Class.MlflowClient.md) | +| `results` | [`EvalResult`](Interface.EvalResult.md)[] | +| `sqlWarehouseId?` | `string` | + +## Returns + +`Promise`\<[`ReportOutcome`](Interface.ReportOutcome.md)\> diff --git a/docs/docs/api/appkit/Function.resolveDatabricksAuth.md b/docs/docs/api/appkit/Function.resolveDatabricksAuth.md new file mode 100644 index 000000000..c3036afbf --- /dev/null +++ b/docs/docs/api/appkit/Function.resolveDatabricksAuth.md @@ -0,0 +1,15 @@ +# Function: resolveDatabricksAuth() + +```ts +function resolveDatabricksAuth(options: ResolveDatabricksAuthOptions): Promise; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `options` | [`ResolveDatabricksAuthOptions`](Interface.ResolveDatabricksAuthOptions.md) | + +## Returns + +`Promise`\<[`DatabricksAuth`](Interface.DatabricksAuth.md) \| `undefined`\> diff --git a/docs/docs/api/appkit/Function.runEval.md b/docs/docs/api/appkit/Function.runEval.md new file mode 100644 index 000000000..9f0fd1a05 --- /dev/null +++ b/docs/docs/api/appkit/Function.runEval.md @@ -0,0 +1,20 @@ +# Function: runEval() + +```ts +function runEval(def: EvalDefinition, options: RunEvalOptions): Promise; +``` + +Run a single eval against a driver. Never throws for assertion or agent +failures — those become a non-passing [EvalResult](Interface.EvalResult.md). Only a malformed +eval definition surfaces as `result.error`. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `def` | [`EvalDefinition`](Interface.EvalDefinition.md) | +| `options` | [`RunEvalOptions`](Interface.RunEvalOptions.md) | + +## Returns + +`Promise`\<[`EvalResult`](Interface.EvalResult.md)\> diff --git a/docs/docs/api/appkit/Function.runEvalsInDir.md b/docs/docs/api/appkit/Function.runEvalsInDir.md new file mode 100644 index 000000000..903559b1b --- /dev/null +++ b/docs/docs/api/appkit/Function.runEvalsInDir.md @@ -0,0 +1,19 @@ +# Function: runEvalsInDir() + +```ts +function runEvalsInDir(options: RunEvalsOptions): Promise; +``` + +Discover, load, and run every eval under each agent's `evals/` dir, driving +the agents on a running app. Never throws for an individual eval — load/run +failures become non-passing [EvalResult](Interface.EvalResult.md)s. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `options` | [`RunEvalsOptions`](Interface.RunEvalsOptions.md) | + +## Returns + +`Promise`\<[`EvalRunSummary`](Interface.EvalRunSummary.md)\> diff --git a/docs/docs/api/appkit/Function.summarize.md b/docs/docs/api/appkit/Function.summarize.md new file mode 100644 index 000000000..959817e62 --- /dev/null +++ b/docs/docs/api/appkit/Function.summarize.md @@ -0,0 +1,15 @@ +# Function: summarize() + +```ts +function summarize(results: EvalResult[]): EvalSummary; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `results` | [`EvalResult`](Interface.EvalResult.md)[] | + +## Returns + +[`EvalSummary`](Interface.EvalSummary.md) diff --git a/docs/docs/api/appkit/Interface.AssertionHandle.md b/docs/docs/api/appkit/Interface.AssertionHandle.md new file mode 100644 index 000000000..02e3c746b --- /dev/null +++ b/docs/docs/api/appkit/Interface.AssertionHandle.md @@ -0,0 +1,53 @@ +# Interface: AssertionHandle + +Chainable handle returned by every assertion to control its severity. +Mirrors eve: assertions are gates by default; `.soft()` demotes to a tracked +metric; `.atLeast(n)` is a soft, score-thresholded assertion. + +## Methods + +### atLeast() + +```ts +atLeast(threshold: number): AssertionHandle; +``` + +Soft assertion that passes only when the score is at least `threshold`. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `threshold` | `number` | + +#### Returns + +`AssertionHandle` + +*** + +### gate() + +```ts +gate(): AssertionHandle; +``` + +Promote to a hard gate — failure fails the eval (non-zero exit). + +#### Returns + +`AssertionHandle` + +*** + +### soft() + +```ts +soft(): AssertionHandle; +``` + +Demote to a tracked metric — doesn't fail unless running with `strict`. + +#### Returns + +`AssertionHandle` diff --git a/docs/docs/api/appkit/Interface.AssertionResult.md b/docs/docs/api/appkit/Interface.AssertionResult.md new file mode 100644 index 000000000..df3e986bf --- /dev/null +++ b/docs/docs/api/appkit/Interface.AssertionResult.md @@ -0,0 +1,43 @@ +# Interface: AssertionResult + +A single recorded assertion outcome. + +## Properties + +### detail? + +```ts +optional detail: string; +``` + +*** + +### label + +```ts +label: string; +``` + +*** + +### pass + +```ts +pass: boolean; +``` + +*** + +### score? + +```ts +optional score: number; +``` + +*** + +### severity + +```ts +severity: Severity; +``` diff --git a/docs/docs/api/appkit/Interface.Assessment.md b/docs/docs/api/appkit/Interface.Assessment.md new file mode 100644 index 000000000..d3537cf00 --- /dev/null +++ b/docs/docs/api/appkit/Interface.Assessment.md @@ -0,0 +1,74 @@ +# Interface: Assessment + +A Feedback assessment in the MLflow REST proto-JSON shape. + +## Properties + +### assessment\_name + +```ts +assessment_name: string; +``` + +*** + +### feedback + +```ts +feedback: { + value: unknown; +}; +``` + +#### value + +```ts +value: unknown; +``` + +*** + +### metadata? + +```ts +optional metadata: Record; +``` + +*** + +### rationale? + +```ts +optional rationale: string; +``` + +*** + +### source + +```ts +source: { + source_id: string; + source_type: "CODE" | "HUMAN" | "LLM_JUDGE"; +}; +``` + +#### source\_id + +```ts +source_id: string; +``` + +#### source\_type + +```ts +source_type: "CODE" | "HUMAN" | "LLM_JUDGE"; +``` + +*** + +### trace\_id + +```ts +trace_id: string; +``` diff --git a/docs/docs/api/appkit/Interface.CustomJudgeSpec.md b/docs/docs/api/appkit/Interface.CustomJudgeSpec.md new file mode 100644 index 000000000..5bfd881e9 --- /dev/null +++ b/docs/docs/api/appkit/Interface.CustomJudgeSpec.md @@ -0,0 +1,27 @@ +# Interface: CustomJudgeSpec + +A custom LLM-judge definition: a prompt template and choice→score mapping. + +## Properties + +### choiceScores + +```ts +choiceScores: Record; +``` + +*** + +### name + +```ts +name: string; +``` + +*** + +### promptTemplate + +```ts +promptTemplate: string; +``` diff --git a/docs/docs/api/appkit/Interface.DatabricksAuth.md b/docs/docs/api/appkit/Interface.DatabricksAuth.md new file mode 100644 index 000000000..b1d31b35b --- /dev/null +++ b/docs/docs/api/appkit/Interface.DatabricksAuth.md @@ -0,0 +1,19 @@ +# Interface: DatabricksAuth + +Resolved Databricks host + bearer token for the eval runner's REST calls. + +## Properties + +### host + +```ts +host: string; +``` + +*** + +### token + +```ts +token: string; +``` diff --git a/docs/docs/api/appkit/Interface.DiscoveredEval.md b/docs/docs/api/appkit/Interface.DiscoveredEval.md new file mode 100644 index 000000000..c64ec7796 --- /dev/null +++ b/docs/docs/api/appkit/Interface.DiscoveredEval.md @@ -0,0 +1,33 @@ +# Interface: DiscoveredEval + +An eval file found under `server/agents//evals/`. + +## Properties + +### agent + +```ts +agent: string; +``` + +The agent id (the `server/agents/` directory name). + +*** + +### file + +```ts +file: string; +``` + +Absolute path to the `*.eval.ts` file. + +*** + +### id + +```ts +id: string; +``` + +Id relative to the agent's evals dir, without `.eval.ts` (e.g. `weather/basic`). diff --git a/docs/docs/api/appkit/Interface.DriveResult.md b/docs/docs/api/appkit/Interface.DriveResult.md new file mode 100644 index 000000000..15625c1ac --- /dev/null +++ b/docs/docs/api/appkit/Interface.DriveResult.md @@ -0,0 +1,53 @@ +# Interface: DriveResult + +What a driver returns for a single `t.send`. + +## Properties + +### reply + +```ts +reply: string; +``` + +The final assistant message text. + +*** + +### sessionId? + +```ts +optional sessionId: string; +``` + +Thread/session id, when the driver exposes one. + +*** + +### succeeded + +```ts +succeeded: boolean; +``` + +Whether the turn completed without an agent/stream error. + +*** + +### toolCalls + +```ts +toolCalls: string[]; +``` + +Names of tools the agent called during the turn. + +*** + +### traceId? + +```ts +optional traceId: string; +``` + +MLflow trace id for the turn, when tracing is enabled on the app. diff --git a/docs/docs/api/appkit/Interface.EvalDefinition.md b/docs/docs/api/appkit/Interface.EvalDefinition.md new file mode 100644 index 000000000..3bf035507 --- /dev/null +++ b/docs/docs/api/appkit/Interface.EvalDefinition.md @@ -0,0 +1,43 @@ +# Interface: EvalDefinition + +A single eval, default-exported from a `*.eval.ts` file. + +## Properties + +### agent? + +```ts +optional agent: string; +``` + +Target agent id. Defaults to the eval's parent `server/agents/` dir. + +*** + +### description? + +```ts +optional description: string; +``` + +Short human description, shown in reports. + +## Methods + +### test() + +```ts +test(t: TestContext): void | Promise; +``` + +The eval body: drive the agent and assert on its behavior. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `t` | [`TestContext`](Interface.TestContext.md) | + +#### Returns + +`void` \| `Promise`\<`void`\> diff --git a/docs/docs/api/appkit/Interface.EvalDriver.md b/docs/docs/api/appkit/Interface.EvalDriver.md new file mode 100644 index 000000000..69d81a05a --- /dev/null +++ b/docs/docs/api/appkit/Interface.EvalDriver.md @@ -0,0 +1,22 @@ +# Interface: EvalDriver + +Abstraction over how the agent is driven. The HTTP driver posts to a running +app's agents endpoint; future drivers (in-process) implement the same shape. + +## Methods + +### send() + +```ts +send(message: string): Promise; +``` + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `message` | `string` | + +#### Returns + +`Promise`\<[`DriveResult`](Interface.DriveResult.md)\> diff --git a/docs/docs/api/appkit/Interface.EvalResult.md b/docs/docs/api/appkit/Interface.EvalResult.md new file mode 100644 index 000000000..94a6c9aff --- /dev/null +++ b/docs/docs/api/appkit/Interface.EvalResult.md @@ -0,0 +1,75 @@ +# Interface: EvalResult + +The outcome of running one eval. + +## Properties + +### assertions + +```ts +assertions: AssertionResult[]; +``` + +*** + +### description? + +```ts +optional description: string; +``` + +*** + +### error? + +```ts +optional error: string; +``` + +Set when the eval threw before completing. + +*** + +### id + +```ts +id: string; +``` + +*** + +### passed + +```ts +passed: boolean; +``` + +True when all gates passed (and, under strict, all soft assertions too). + +*** + +### skipped? + +```ts +optional skipped: { + reason?: string; +}; +``` + +Set when the eval called `t.skip`. + +#### reason? + +```ts +optional reason: string; +``` + +*** + +### traceId? + +```ts +optional traceId: string; +``` + +MLflow trace id of the eval's last turn, for attaching assessments. diff --git a/docs/docs/api/appkit/Interface.EvalRunSummary.md b/docs/docs/api/appkit/Interface.EvalRunSummary.md new file mode 100644 index 000000000..ec9176c00 --- /dev/null +++ b/docs/docs/api/appkit/Interface.EvalRunSummary.md @@ -0,0 +1,41 @@ +# Interface: EvalRunSummary + +## Properties + +### mlflow? + +```ts +optional mlflow: { + finish: FinishOutcome; + report: ReportOutcome; + runId: string; +}; +``` + +Present when an MLflow evaluation run was created. + +#### finish + +```ts +finish: FinishOutcome; +``` + +#### report + +```ts +report: ReportOutcome; +``` + +#### runId + +```ts +runId: string; +``` + +*** + +### results + +```ts +results: EvalResult[]; +``` diff --git a/docs/docs/api/appkit/Interface.EvalSummary.md b/docs/docs/api/appkit/Interface.EvalSummary.md new file mode 100644 index 000000000..3c8fc1e6e --- /dev/null +++ b/docs/docs/api/appkit/Interface.EvalSummary.md @@ -0,0 +1,43 @@ +# Interface: EvalSummary + +## Properties + +### allPassed + +```ts +allPassed: boolean; +``` + +True when no eval failed (skips don't count as failures). + +*** + +### failed + +```ts +failed: number; +``` + +*** + +### passed + +```ts +passed: number; +``` + +*** + +### skipped + +```ts +skipped: number; +``` + +*** + +### total + +```ts +total: number; +``` diff --git a/docs/docs/api/appkit/Interface.HttpDriverOptions.md b/docs/docs/api/appkit/Interface.HttpDriverOptions.md new file mode 100644 index 000000000..26e3fde35 --- /dev/null +++ b/docs/docs/api/appkit/Interface.HttpDriverOptions.md @@ -0,0 +1,65 @@ +# Interface: HttpDriverOptions + +## Properties + +### agent? + +```ts +optional agent: string; +``` + +Agent alias to target. Omit to use the app's default agent. + +*** + +### baseUrl + +```ts +baseUrl: string; +``` + +Base URL of the running app, e.g. `http://localhost:3000`. + +*** + +### headers? + +```ts +optional headers: Record; +``` + +Extra request headers (e.g. auth for a deployed app). + +*** + +### mlflowRunId? + +```ts +optional mlflowRunId: string; +``` + +MLflow run id to link each turn's trace to (for evaluation runs). + +*** + +### path? + +```ts +optional path: string; +``` + +Chat endpoint path. Defaults to `/api/agents/chat`. + +*** + +### timeoutMs? + +```ts +optional timeoutMs: number; +``` + +Max wall-clock time for a single turn before it is abandoned as a failed +turn (`succeeded: false`). Without this a hung agent — a blocked tool, a +stalled model — never ends the SSE stream (heartbeats keep it alive), so +the read loop spins forever and wedges the whole sequential suite. +Defaults to 120s. diff --git a/docs/docs/api/appkit/Interface.JudgeConfig.md b/docs/docs/api/appkit/Interface.JudgeConfig.md new file mode 100644 index 000000000..e20780ce0 --- /dev/null +++ b/docs/docs/api/appkit/Interface.JudgeConfig.md @@ -0,0 +1,31 @@ +# Interface: JudgeConfig + +## Properties + +### client + +```ts +client: MlflowClient; +``` + +Client for the workspace hosting the judge serving endpoint. + +*** + +### model + +```ts +model: string; +``` + +Serving endpoint name used as the judge model. + +*** + +### token + +```ts +token: string; +``` + +Bearer token for the serving endpoint. diff --git a/docs/docs/api/appkit/Interface.JudgeScore.md b/docs/docs/api/appkit/Interface.JudgeScore.md new file mode 100644 index 000000000..d4bf0519f --- /dev/null +++ b/docs/docs/api/appkit/Interface.JudgeScore.md @@ -0,0 +1,19 @@ +# Interface: JudgeScore + +A normalized judge result. `score` is 0..1. + +## Properties + +### rationale? + +```ts +optional rationale: string; +``` + +*** + +### score + +```ts +score: number; +``` diff --git a/docs/docs/api/appkit/Interface.MatchResult.md b/docs/docs/api/appkit/Interface.MatchResult.md new file mode 100644 index 000000000..a8744ef07 --- /dev/null +++ b/docs/docs/api/appkit/Interface.MatchResult.md @@ -0,0 +1,31 @@ +# Interface: MatchResult + +Result of a deterministic matcher run against a value. + +## Properties + +### detail? + +```ts +optional detail: string; +``` + +Human-readable explanation, shown on failure. + +*** + +### pass + +```ts +pass: boolean; +``` + +*** + +### score? + +```ts +optional score: number; +``` + +Optional 0..1 score for scored matchers (similarity, judges). diff --git a/docs/docs/api/appkit/Interface.PostResult.md b/docs/docs/api/appkit/Interface.PostResult.md new file mode 100644 index 000000000..35bcf684a --- /dev/null +++ b/docs/docs/api/appkit/Interface.PostResult.md @@ -0,0 +1,27 @@ +# Interface: PostResult + +Structured result for a best-effort POST that must not throw. + +## Properties + +### error? + +```ts +optional error: string; +``` + +*** + +### ok + +```ts +ok: boolean; +``` + +*** + +### status? + +```ts +optional status: number; +``` diff --git a/docs/docs/api/appkit/Interface.ReportOutcome.md b/docs/docs/api/appkit/Interface.ReportOutcome.md new file mode 100644 index 000000000..f42136a9b --- /dev/null +++ b/docs/docs/api/appkit/Interface.ReportOutcome.md @@ -0,0 +1,47 @@ +# Interface: ReportOutcome + +## Properties + +### failures + +```ts +failures: { + error?: string; + status?: number; + traceId: string; +}[]; +``` + +#### error? + +```ts +optional error: string; +``` + +#### status? + +```ts +optional status: number; +``` + +#### traceId + +```ts +traceId: string; +``` + +*** + +### skipped + +```ts +skipped: number; +``` + +*** + +### written + +```ts +written: number; +``` diff --git a/docs/docs/api/appkit/Interface.ResolveDatabricksAuthOptions.md b/docs/docs/api/appkit/Interface.ResolveDatabricksAuthOptions.md new file mode 100644 index 000000000..d13080407 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResolveDatabricksAuthOptions.md @@ -0,0 +1,31 @@ +# Interface: ResolveDatabricksAuthOptions + +## Properties + +### host? + +```ts +optional host: string; +``` + +Explicit host; wins over the profile/SDK-resolved host when set. + +*** + +### profile? + +```ts +optional profile: string; +``` + +`~/.databrickscfg` profile to authenticate with (e.g. `dogfood`). + +*** + +### token? + +```ts +optional token: string; +``` + +Explicit bearer token; when set, no OAuth is minted (PAT/CI path). diff --git a/docs/docs/api/appkit/Interface.RunEvalOptions.md b/docs/docs/api/appkit/Interface.RunEvalOptions.md new file mode 100644 index 000000000..16a6b8190 --- /dev/null +++ b/docs/docs/api/appkit/Interface.RunEvalOptions.md @@ -0,0 +1,31 @@ +# Interface: RunEvalOptions + +## Properties + +### driver + +```ts +driver: EvalDriver; +``` + +Drives the agent and returns reply/tool-calls/success per `send`. + +*** + +### id + +```ts +id: string; +``` + +Stable id for the eval (e.g. its file path relative to the evals dir). + +*** + +### strict? + +```ts +optional strict: boolean; +``` + +When true, soft assertion failures also fail the eval. diff --git a/docs/docs/api/appkit/Interface.RunEvalsOptions.md b/docs/docs/api/appkit/Interface.RunEvalsOptions.md new file mode 100644 index 000000000..48df08c07 --- /dev/null +++ b/docs/docs/api/appkit/Interface.RunEvalsOptions.md @@ -0,0 +1,180 @@ +# Interface: RunEvalsOptions + +## Properties + +### baseUrl + +```ts +baseUrl: string; +``` + +Base URL of the running app to drive, e.g. `http://localhost:3000`. + +*** + +### concurrency? + +```ts +optional concurrency: number; +``` + +Max evals to drive concurrently. Each eval opens one stream to the app as +the same user, so keep this at or below the app's +`maxConcurrentStreamsPerUser` (default 5) or the surplus streams hit the +429 guard. Defaults to 4; clamped to `[1, total]`. + +*** + +### filter? + +```ts +optional filter: string; +``` + +Substring filter on `/` (or an exact agent id). + +*** + +### headers? + +```ts +optional headers: Record; +``` + +Extra request headers for the driver (e.g. auth for a deployed app). + +*** + +### judge? + +```ts +optional judge: { + host: string; + model: string; + token: string; +}; +``` + +When set, enable `t.judge.*` LLM-as-judge scoring via autoevals against a +Databricks serving endpoint (`model`). + +#### host + +```ts +host: string; +``` + +#### model + +```ts +model: string; +``` + +#### token + +```ts +token: string; +``` + +*** + +### mlflow? + +```ts +optional mlflow: { + experimentId: string; + host: string; + sqlWarehouseId?: string; + token: string; +}; +``` + +When set, create a native MLflow "Evaluation run": each eval's trace is +linked to the run, pass/fail is written as feedback, and aggregate metrics +are logged. Requires Databricks creds + the target experiment. + +#### experimentId + +```ts +experimentId: string; +``` + +#### host + +```ts +host: string; +``` + +#### sqlWarehouseId? + +```ts +optional sqlWarehouseId: string; +``` + +SQL warehouse id for writing assessments to UC-backed (V4) traces. + +#### token + +```ts +token: string; +``` + +*** + +### now? + +```ts +optional now: number; +``` + +Wall-clock timestamp (ms) for run create/finish — pass `Date.now()`. + +*** + +### onEvent()? + +```ts +optional onEvent: (event: EvalProgress) => void; +``` + +Progress callback, invoked as evals are discovered, started, and finished. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `event` | [`EvalProgress`](TypeAlias.EvalProgress.md) | + +#### Returns + +`void` + +*** + +### rootDir? + +```ts +optional rootDir: string; +``` + +Project root containing `server/agents/`. Defaults to `process.cwd()`. + +*** + +### strict? + +```ts +optional strict: boolean; +``` + +Soft assertion failures also fail the eval. + +*** + +### timeoutMs? + +```ts +optional timeoutMs: number; +``` + +Per-turn wall-clock timeout (ms) before a turn is failed. Defaults to 120s. diff --git a/docs/docs/api/appkit/Interface.TestContext.md b/docs/docs/api/appkit/Interface.TestContext.md new file mode 100644 index 000000000..e21156235 --- /dev/null +++ b/docs/docs/api/appkit/Interface.TestContext.md @@ -0,0 +1,199 @@ +# Interface: TestContext + +The `t` context passed to an eval's `test` function. + +## Properties + +### judge + +```ts +judge: { + closedQA: Promise; + custom: Promise; + factuality: Promise; +}; +``` + +LLM-as-judge scoring of the last reply (via autoevals → a Databricks judge +model). Each returns a scored, soft-by-default assertion; chain `.atLeast(n)` +to set the pass threshold or `.gate()` to make it a hard gate. Requires the +judge to be configured (`--judge-model`). + +#### closedQA() + +```ts +closedQA(criteria: string): Promise; +``` + +Score whether the reply answers the question, per optional `criteria`. + +##### Parameters + +| Parameter | Type | +| ------ | ------ | +| `criteria` | `string` | + +##### Returns + +`Promise`\<[`AssertionHandle`](Interface.AssertionHandle.md)\> + +#### custom() + +```ts +custom(spec: CustomJudgeSpec): Promise; +``` + +A custom prompt-template judge (the TS analog of MLflow's `@scorer`). + +##### Parameters + +| Parameter | Type | +| ------ | ------ | +| `spec` | [`CustomJudgeSpec`](Interface.CustomJudgeSpec.md) | + +##### Returns + +`Promise`\<[`AssertionHandle`](Interface.AssertionHandle.md)\> + +#### factuality() + +```ts +factuality(expected: string): Promise; +``` + +Score factuality of the reply against an expected reference. + +##### Parameters + +| Parameter | Type | +| ------ | ------ | +| `expected` | `string` | + +##### Returns + +`Promise`\<[`AssertionHandle`](Interface.AssertionHandle.md)\> + +*** + +### reply + +```ts +readonly reply: string; +``` + +The last assistant reply. + +*** + +### sessionId + +```ts +readonly sessionId: string | undefined; +``` + +The current session/thread id, if any. + +*** + +### toolCalls + +```ts +readonly toolCalls: string[]; +``` + +Tools called during the last turn. + +## Methods + +### calledTool() + +```ts +calledTool(name: string): AssertionHandle; +``` + +Assert a tool was called during the run (gate by default). + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `name` | `string` | + +#### Returns + +[`AssertionHandle`](Interface.AssertionHandle.md) + +*** + +### check() + +```ts +check(value: string, matcher: Matcher): AssertionHandle; +``` + +Assert a value against a matcher, e.g. `t.check(t.reply, includes("Sunny"))`. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `value` | `string` | +| `matcher` | [`Matcher`](TypeAlias.Matcher.md) | + +#### Returns + +[`AssertionHandle`](Interface.AssertionHandle.md) + +*** + +### send() + +```ts +send(message: string): Promise; +``` + +Send a user message to the agent and capture its response. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `message` | `string` | + +#### Returns + +`Promise`\<`void`\> + +*** + +### skip() + +```ts +skip(reason?: string): never; +``` + +Skip this eval with an optional reason. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `reason?` | `string` | + +#### Returns + +`never` + +*** + +### succeeded() + +```ts +succeeded(): AssertionHandle; +``` + +Assert the last turn completed successfully (gate by default). + +#### Returns + +[`AssertionHandle`](Interface.AssertionHandle.md) diff --git a/docs/docs/api/appkit/TypeAlias.EvalProgress.md b/docs/docs/api/appkit/TypeAlias.EvalProgress.md new file mode 100644 index 000000000..d1814ba6a --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.EvalProgress.md @@ -0,0 +1,25 @@ +# Type Alias: EvalProgress + +```ts +type EvalProgress = + | { + total: number; + type: "discovered"; +} + | { + runId: string; + type: "run-created"; +} + | { + id: string; + index: number; + total: number; + type: "start"; +} + | { + index: number; + result: EvalResult; + total: number; + type: "result"; +}; +``` diff --git a/docs/docs/api/appkit/TypeAlias.Matcher.md b/docs/docs/api/appkit/TypeAlias.Matcher.md new file mode 100644 index 000000000..f22e48787 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.Matcher.md @@ -0,0 +1,17 @@ +# Type Alias: Matcher() + +```ts +type Matcher = (value: string) => MatchResult; +``` + +A deterministic matcher: inspects a string value and returns a result. + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `value` | `string` | + +## Returns + +[`MatchResult`](Interface.MatchResult.md) diff --git a/docs/docs/api/appkit/TypeAlias.Severity.md b/docs/docs/api/appkit/TypeAlias.Severity.md new file mode 100644 index 000000000..575bad861 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.Severity.md @@ -0,0 +1,7 @@ +# Type Alias: Severity + +```ts +type Severity = "gate" | "soft"; +``` + +Whether an assertion fails the eval (`gate`) or is tracked only (`soft`). diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 884db87f6..42afc4a50 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -22,6 +22,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [DatabricksAdapter](Class.DatabricksAdapter.md) | Adapter that talks directly to Databricks Model Serving `/invocations` endpoint. | | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | +| [MlflowClient](Class.MlflowClient.md) | A thin client over the Databricks workspace REST API, owning the host + bearer token so callers (eval-run creation, assessment writes, the judge's serving endpoint) don't each re-derive URLs or re-attach auth. The host is normalized once at construction. | | [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins. | | [PolicyDeniedError](Class.PolicyDeniedError.md) | Thrown when a policy denies an action. | | [ResourceRegistry](Class.ResourceRegistry.md) | Central registry for tracking plugin resource requirements. Deduplication uses type + resourceKey (machine-stable); alias is for display only. | @@ -40,18 +41,31 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [AgentRunContext](Interface.AgentRunContext.md) | - | | [AgentsPluginConfig](Interface.AgentsPluginConfig.md) | Base configuration interface for AppKit plugins | | [AgentToolDefinition](Interface.AgentToolDefinition.md) | - | +| [AssertionHandle](Interface.AssertionHandle.md) | Chainable handle returned by every assertion to control its severity. Mirrors eve: assertions are gates by default; `.soft()` demotes to a tracked metric; `.atLeast(n)` is a soft, score-thresholded assertion. | +| [AssertionResult](Interface.AssertionResult.md) | A single recorded assertion outcome. | +| [Assessment](Interface.Assessment.md) | A Feedback assessment in the MLflow REST proto-JSON shape. | | [AutoInheritToolsConfig](Interface.AutoInheritToolsConfig.md) | Auto-inherit configuration. When enabled for a given agent origin, agents with no explicit `tools:` declaration receive every registered ToolProvider plugin tool whose author marked `autoInheritable: true`. Tools without that flag — destructive, state-mutating, or privilege-sensitive — never spread automatically and must be wired via `tools:` (object or function form in code, `plugin:NAME` entries in markdown frontmatter). | | [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins | | [CacheConfig](Interface.CacheConfig.md) | Configuration for the CacheInterceptor. Controls TTL, size limits, storage backend, and probabilistic cleanup. | +| [CustomJudgeSpec](Interface.CustomJudgeSpec.md) | A custom LLM-judge definition: a prompt template and choice→score mapping. | | [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection | | [DatabaseRegistry](Interface.DatabaseRegistry.md) | CANONICAL augmentation target. Empty by default; the generated `database.d.ts` augments it via `declare module "@databricks/appkit" { interface DatabaseRegistry { ... } }`. | +| [DatabricksAuth](Interface.DatabricksAuth.md) | Resolved Databricks host + bearer token for the eval runner's REST calls. | +| [DiscoveredEval](Interface.DiscoveredEval.md) | An eval file found under `server/agents//evals/`. | +| [DriveResult](Interface.DriveResult.md) | What a driver returns for a single `t.send`. | | [EndpointConfig](Interface.EndpointConfig.md) | - | +| [EvalDefinition](Interface.EvalDefinition.md) | A single eval, default-exported from a `*.eval.ts` file. | +| [EvalDriver](Interface.EvalDriver.md) | Abstraction over how the agent is driven. The HTTP driver posts to a running app's agents endpoint; future drivers (in-process) implement the same shape. | +| [EvalResult](Interface.EvalResult.md) | The outcome of running one eval. | +| [EvalRunSummary](Interface.EvalRunSummary.md) | - | +| [EvalSummary](Interface.EvalSummary.md) | - | | [FilePolicyUser](Interface.FilePolicyUser.md) | Minimal user identity passed to the policy function. | | [FileResource](Interface.FileResource.md) | Describes the file or directory being acted upon. | | [FunctionTool](Interface.FunctionTool.md) | - | | [GenerateDatabaseCredentialRequest](Interface.GenerateDatabaseCredentialRequest.md) | Request parameters for generating database OAuth credentials | | [GenerationParams](Interface.GenerationParams.md) | Optional generation parameters forwarded to the OpenAI-compatible serving request body. Names match the serving API wire keys. Only keys that are set are sent — undefined values are omitted so the endpoint applies its own defaults. Ranges are not validated here; the serving endpoint validates. | | [HostedSupervisorTool](Interface.HostedSupervisorTool.md) | Tagged record returned by every [supervisorTools](Variable.supervisorTools.md) factory. The `__kind` discriminator lets the agents plugin (and standalone `runAgent`) classify these tools without a structural match against the wire format — keeps the SA wire shape free to evolve and avoids namespace collisions with MCP hosted tools (which use `type: "genie-space"` hyphenated, vs SA's `type: "genie_space"` underscored). | +| [HttpDriverOptions](Interface.HttpDriverOptions.md) | - | | [IAiSearchConfig](Interface.IAiSearchConfig.md) | Base configuration interface for AppKit plugins | | [IJobsConfig](Interface.IJobsConfig.md) | Configuration for the Jobs plugin. | | [IndexConfig](Interface.IndexConfig.md) | - | @@ -59,22 +73,30 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [JobAPI](Interface.JobAPI.md) | User-facing API for a single configured job. | | [JobConfig](Interface.JobConfig.md) | Per-job configuration options. | | [JobsConnectorConfig](Interface.JobsConnectorConfig.md) | - | +| [JudgeConfig](Interface.JudgeConfig.md) | - | +| [JudgeScore](Interface.JudgeScore.md) | A normalized judge result. `score` is 0..1. | | [LakebasePool](Interface.LakebasePool.md) | Subset of `pg.Pool` exposed by the Lakebase plugin. | | [LakebasePoolConfig](Interface.LakebasePoolConfig.md) | Configuration for creating a Lakebase connection pool | | [LakebasePoolManager](Interface.LakebasePoolManager.md) | Manages multiple Lakebase connection pools keyed by an identifier (e.g. userId). | +| [MatchResult](Interface.MatchResult.md) | Result of a deterministic matcher run against a value. | | [McpConnectAllResult](Interface.McpConnectAllResult.md) | Per-endpoint outcome of [AppKitMcpClient.connectAll](Class.AppKitMcpClient.md#connectall). Callers (the agents plugin in particular) use the split to warn at startup when some MCP servers are unreachable without aborting boot for the rest. | | [Message](Interface.Message.md) | - | | [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. Extends the shared PluginManifest with strict resource types. | | [PluginToolkitProvider](Interface.PluginToolkitProvider.md) | Minimum shape every entry in the [Plugins](TypeAlias.Plugins.md) map must expose. Core plugins (analytics, files, genie, lakebase) implement this directly via their `.toolkit()` method. The agents plugin and standalone `runAgent` synthesize this shape for any registered plugin that doesn't implement `.toolkit()` directly (falling back to `getAgentTools()` walking). | +| [PostResult](Interface.PostResult.md) | Structured result for a best-effort POST that must not throw. | | [PromptContext](Interface.PromptContext.md) | Context passed to `baseSystemPrompt` callbacks. | | [RegisteredAgent](Interface.RegisteredAgent.md) | - | +| [ReportOutcome](Interface.ReportOutcome.md) | - | | [RequestedClaims](Interface.RequestedClaims.md) | Optional claims for fine-grained Unity Catalog table permissions When specified, the returned token will be scoped to only the requested tables | | [RequestedResource](Interface.RequestedResource.md) | Resource to request permissions for in Unity Catalog | | [RerankerConfig](Interface.RerankerConfig.md) | - | +| [ResolveDatabricksAuthOptions](Interface.ResolveDatabricksAuthOptions.md) | - | | [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. | | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [RunAgentInput](Interface.RunAgentInput.md) | - | | [RunAgentResult](Interface.RunAgentResult.md) | - | +| [RunEvalOptions](Interface.RunEvalOptions.md) | - | +| [RunEvalsOptions](Interface.RunEvalsOptions.md) | - | | [Schema](Interface.Schema.md) | One finalized schema. `TTableName` keeps the declared names in the type, so configuration that addresses a table by name is checked against the schema it was written for. Code that accepts any schema uses the default. | | [SearchRequest](Interface.SearchRequest.md) | - | | [SearchResponse](Interface.SearchResponse.md) | - | @@ -85,6 +107,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [SupervisorApiAdapterOptions](Interface.SupervisorApiAdapterOptions.md) | - | | [SupervisorExtension](Interface.SupervisorExtension.md) | Shape of the value at `AgentInput.extensions[SUPERVISOR_EXTENSION_KEY]`. The agents plugin / `runAgent` build this from the tool index; advanced callers invoking `adapter.run(...)` directly populate it themselves. | | [TelemetryConfig](Interface.TelemetryConfig.md) | OpenTelemetry configuration for AppKit applications | +| [TestContext](Interface.TestContext.md) | The `t` context passed to an eval's `test` function. | | [Thread](Interface.Thread.md) | - | | [ThreadStore](Interface.ThreadStore.md) | - | | [ToolAnnotations](Interface.ToolAnnotations.md) | - | @@ -109,6 +132,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [BaseSystemPromptOption](TypeAlias.BaseSystemPromptOption.md) | - | | [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. | | [DatabaseExports](TypeAlias.DatabaseExports.md) | Typed database API published by the plugin. | +| [EvalProgress](TypeAlias.EvalProgress.md) | - | | [ExecutionResult](TypeAlias.ExecutionResult.md) | Discriminated union for plugin execution results. | | [FileAction](TypeAlias.FileAction.md) | Every action the files plugin can perform. | | [FilePolicy](TypeAlias.FilePolicy.md) | A policy function that decides whether `user` may perform `action` on `resource`. Return `true` to allow, `false` to deny. | @@ -116,6 +140,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | | [IDatabaseConfig](TypeAlias.IDatabaseConfig.md) | Configuration for one schema-bound DatabasePlugin instance. | | [JobsExport](TypeAlias.JobsExport.md) | Public API shape of the jobs plugin. Callable to select a job by key. | +| [Matcher](TypeAlias.Matcher.md) | A deterministic matcher: inspects a string value and returns a result. | | [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. | | [Plugins](TypeAlias.Plugins.md) | Plugin map passed to the function form of [AgentDefinition.tools](Interface.AgentDefinition.md#tools). Each entry exposes a `.toolkit(opts?)` method that returns a record of [ToolkitEntry](Interface.ToolkitEntry.md) markers ready to be spread into a tool record. | | [ResolvedToolEntry](TypeAlias.ResolvedToolEntry.md) | Internal tool-index entry after a tool record has been resolved to a dispatchable form. | @@ -123,6 +148,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | | [SearchFilters](TypeAlias.SearchFilters.md) | - | | [ServingFactory](TypeAlias.ServingFactory.md) | Factory function returned by `AppKit.serving`. | +| [Severity](TypeAlias.Severity.md) | Whether an assertion fails the eval (`gate`) or is tracked only (`soft`). | | [SupervisorTool](TypeAlias.SupervisorTool.md) | Tools supported by the Databricks AI Gateway Responses API. The shapes match the wire format the endpoint expects, so the adapter passes the array straight into the request body. | | [ToolRegistry](TypeAlias.ToolRegistry.md) | - | | [ToPlugin](TypeAlias.ToPlugin.md) | Factory function type returned by `toPlugin()`. Accepts optional config and returns a PluginData tuple. | @@ -149,20 +175,31 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [bigid](Function.bigid.md) | - | | [bigint](Function.bigint.md) | - | | [boolean](Function.boolean.md) | - | +| [buildAssessments](Function.buildAssessments.md) | - | +| [configureJudge](Function.configureJudge.md) | Configure the judge once. Sets the OpenAI-compatible client env autoevals reads and the default judge model. No-op-safe: on failure, judging stays disabled and [isJudgeConfigured](Function.isJudgeConfigured.md) returns false. | | [createAgent](Function.createAgent.md) | Pure factory for agent definitions: cycle-detects the sub-agent graph and returns the same object, stamped with a non-enumerable AGENT\_BRAND so discovery recognizes it. Safe at module top-level; no adapter is built. Don't `Object.freeze` the definition before passing it in — the brand is written onto the argument. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | +| [createHttpDriver](Function.createHttpDriver.md) | Drives an agent by POSTing to a running app's chat endpoint and parsing the SSE response. Keeps the thread id across `send`s so multi-turn evals share a conversation. Agent/stream errors surface as `succeeded: false` rather than throwing, so `t.succeeded()` can assert on them. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | | [createLakebasePoolManager](Function.createLakebasePoolManager.md) | Create a pool manager that maintains per-key Lakebase connection pools. | | [createWorkspaceClient](Function.createWorkspaceClient.md) | Construct an AppKit workspace client. | | [database](Function.database.md) | Create a typed database plugin registration for a finalized schema. | +| [defineEval](Function.defineEval.md) | Define an agent eval. Default-export the result from a `server/agents//evals/*.eval.ts` file. | | [defineManifest](Function.defineManifest.md) | Validates a raw manifest (typically a `manifest.json` import) against the canonical Zod schema and returns it as a strict [PluginManifest](Interface.PluginManifest.md). | | [defineSchema](Function.defineSchema.md) | Compile one declared schema. The returned type keeps the table names the builder returned, so `crudRoutes` and `hooks` can name only real tables. | | [defineTool](Function.defineTool.md) | Defines a single tool entry for a plugin's internal registry. | +| [discoverEvalFiles](Function.discoverEvalFiles.md) | Discover evals under `/server/agents//evals/` — co-located with each agent's `agent.{md,ts}` (same folder-per-agent layout the agents plugin discovers). The agent id is the folder name; the eval id is the file path relative to that evals dir with `.eval.ts` stripped. Sorted + stable. | | [enumColumn](Function.enumColumn.md) | - | +| [equals](Function.equals.md) | Passes when the value equals `expected` exactly. | +| [evalGlyph](Function.evalGlyph.md) | Status glyph for a single eval result. | | [executeFromRegistry](Function.executeFromRegistry.md) | Validates tool-call arguments against the entry's schema and invokes its handler. On validation failure, returns an LLM-friendly error string (matching the behavior of `tool()`) rather than throwing, so the model can self-correct on its next turn. | | [extractServingEndpoints](Function.extractServingEndpoints.md) | Extract serving endpoint config from a server file by AST-parsing it. Looks for `serving({ endpoints: { alias: { env: "..." }, ... } })` calls and extracts the endpoint alias names and their environment variable mappings. | | [findServerFile](Function.findServerFile.md) | Find the server entry file by checking candidate paths in order. | | [fk](Function.fk.md) | Declare foreign-key to another column. | +| [formatEvalDetail](Function.formatEvalDetail.md) | Indented detail lines for a failing eval (error + failing assertions). | +| [formatEvalHeadline](Function.formatEvalHeadline.md) | The one-line header for a single eval result (no failure detail). | +| [formatEvalResults](Function.formatEvalResults.md) | Render all results as a human-readable console report (non-streaming). | +| [formatSummaryLine](Function.formatSummaryLine.md) | The final PASS/FAIL summary line. | | [fromSupervisorApi](Function.fromSupervisorApi.md) | Creates an [AgentAdapter](Interface.AgentAdapter.md) backed by the Databricks AI Gateway Responses API (`/ai-gateway/mlflow/v1/responses`). | | [functionToolToDefinition](Function.functionToolToDefinition.md) | - | | [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. | @@ -174,19 +211,28 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [getUsernameWithApiLookup](Function.getUsernameWithApiLookup.md) | Resolves the PostgreSQL username for a Lakebase connection. | | [getWorkspaceClient](Function.getWorkspaceClient.md) | Get workspace client from config or SDK default auth chain | | [id](Function.id.md) | - | +| [includes](Function.includes.md) | Passes when the value contains `substring`. | | [integer](Function.integer.md) | - | | [isFunctionTool](Function.isFunctionTool.md) | - | | [isHostedTool](Function.isHostedTool.md) | - | +| [isJudgeConfigured](Function.isJudgeConfigured.md) | - | | [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker | | [isSupervisorTool](Function.isSupervisorTool.md) | Type guard for [HostedSupervisorTool](Interface.HostedSupervisorTool.md). Used by the agents plugin (`buildToolIndex`) and standalone `runAgent` (`classifyTool`) to route supervisor-hosted tools to the extensions payload rather than the adapter's `tools` array. | | [isToolkitEntry](Function.isToolkitEntry.md) | Type guard for `ToolkitEntry` — used by the agents plugin to differentiate toolkit references from inline tools in a mixed `tools` record. | | [jsonb](Function.jsonb.md) | - | | [loadAgentFromFile](Function.loadAgentFromFile.md) | Loads a single markdown agent file and resolves its frontmatter against registered plugin toolkits + ambient tool library. | | [loadAgentsFromDir](Function.loadAgentsFromDir.md) | Scans a directory for one subdirectory per agent, each containing `agent.md` (frontmatter + body). Produces an `AgentDefinition` record keyed by agent id (folder name). Throws on frontmatter errors or unresolved references. Returns an empty map if the directory does not exist. | +| [matches](Function.matches.md) | Passes when the value matches `pattern`. | | [mcpServer](Function.mcpServer.md) | Factory for declaring a custom MCP server tool. | +| [normalizeHost](Function.normalizeHost.md) | Ensure the host has a scheme (Databricks env often lacks `https://`). | | [parseTextToolCalls](Function.parseTextToolCalls.md) | Parses text-based tool calls from model output. | +| [reportToMlflow](Function.reportToMlflow.md) | Write one pass/fail assessment per eval result to the Databricks MLflow REST API. Never throws — failures are collected so the run still reports. | +| [resolveDatabricksAuth](Function.resolveDatabricksAuth.md) | - | | [resolveHostedTools](Function.resolveHostedTools.md) | - | | [runAgent](Function.runAgent.md) | Standalone agent execution without `createApp`. Resolves the adapter, binds inline tools, and drives the adapter's `run()` loop to completion. | +| [runEval](Function.runEval.md) | Run a single eval against a driver. Never throws for assertion or agent failures — those become a non-passing [EvalResult](Interface.EvalResult.md). Only a malformed eval definition surfaces as `result.error`. | +| [runEvalsInDir](Function.runEvalsInDir.md) | Discover, load, and run every eval under each agent's `evals/` dir, driving the agents on a running app. Never throws for an individual eval — load/run failures become non-passing [EvalResult](Interface.EvalResult.md)s. | +| [summarize](Function.summarize.md) | - | | [text](Function.text.md) | - | | [timestamp](Function.timestamp.md) | - | | [tool](Function.tool.md) | Factory for defining function tools with Zod schemas. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 3999e830c..6dd11de11 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -61,6 +61,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Class.InitializationError", label: "InitializationError" }, + { + type: "doc", + id: "api/appkit/Class.MlflowClient", + label: "MlflowClient" + }, { type: "doc", id: "api/appkit/Class.Plugin", @@ -132,6 +137,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.AgentToolDefinition", label: "AgentToolDefinition" }, + { + type: "doc", + id: "api/appkit/Interface.AssertionHandle", + label: "AssertionHandle" + }, + { + type: "doc", + id: "api/appkit/Interface.AssertionResult", + label: "AssertionResult" + }, + { + type: "doc", + id: "api/appkit/Interface.Assessment", + label: "Assessment" + }, { type: "doc", id: "api/appkit/Interface.AutoInheritToolsConfig", @@ -147,6 +167,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.CacheConfig", label: "CacheConfig" }, + { + type: "doc", + id: "api/appkit/Interface.CustomJudgeSpec", + label: "CustomJudgeSpec" + }, { type: "doc", id: "api/appkit/Interface.DatabaseCredential", @@ -157,11 +182,51 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.DatabaseRegistry", label: "DatabaseRegistry" }, + { + type: "doc", + id: "api/appkit/Interface.DatabricksAuth", + label: "DatabricksAuth" + }, + { + type: "doc", + id: "api/appkit/Interface.DiscoveredEval", + label: "DiscoveredEval" + }, + { + type: "doc", + id: "api/appkit/Interface.DriveResult", + label: "DriveResult" + }, { type: "doc", id: "api/appkit/Interface.EndpointConfig", label: "EndpointConfig" }, + { + type: "doc", + id: "api/appkit/Interface.EvalDefinition", + label: "EvalDefinition" + }, + { + type: "doc", + id: "api/appkit/Interface.EvalDriver", + label: "EvalDriver" + }, + { + type: "doc", + id: "api/appkit/Interface.EvalResult", + label: "EvalResult" + }, + { + type: "doc", + id: "api/appkit/Interface.EvalRunSummary", + label: "EvalRunSummary" + }, + { + type: "doc", + id: "api/appkit/Interface.EvalSummary", + label: "EvalSummary" + }, { type: "doc", id: "api/appkit/Interface.FilePolicyUser", @@ -192,6 +257,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.HostedSupervisorTool", label: "HostedSupervisorTool" }, + { + type: "doc", + id: "api/appkit/Interface.HttpDriverOptions", + label: "HttpDriverOptions" + }, { type: "doc", id: "api/appkit/Interface.IAiSearchConfig", @@ -227,6 +297,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.JobsConnectorConfig", label: "JobsConnectorConfig" }, + { + type: "doc", + id: "api/appkit/Interface.JudgeConfig", + label: "JudgeConfig" + }, + { + type: "doc", + id: "api/appkit/Interface.JudgeScore", + label: "JudgeScore" + }, { type: "doc", id: "api/appkit/Interface.LakebasePool", @@ -242,6 +322,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.LakebasePoolManager", label: "LakebasePoolManager" }, + { + type: "doc", + id: "api/appkit/Interface.MatchResult", + label: "MatchResult" + }, { type: "doc", id: "api/appkit/Interface.McpConnectAllResult", @@ -262,6 +347,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.PluginToolkitProvider", label: "PluginToolkitProvider" }, + { + type: "doc", + id: "api/appkit/Interface.PostResult", + label: "PostResult" + }, { type: "doc", id: "api/appkit/Interface.PromptContext", @@ -272,6 +362,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.RegisteredAgent", label: "RegisteredAgent" }, + { + type: "doc", + id: "api/appkit/Interface.ReportOutcome", + label: "ReportOutcome" + }, { type: "doc", id: "api/appkit/Interface.RequestedClaims", @@ -287,6 +382,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.RerankerConfig", label: "RerankerConfig" }, + { + type: "doc", + id: "api/appkit/Interface.ResolveDatabricksAuthOptions", + label: "ResolveDatabricksAuthOptions" + }, { type: "doc", id: "api/appkit/Interface.ResourceEntry", @@ -307,6 +407,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.RunAgentResult", label: "RunAgentResult" }, + { + type: "doc", + id: "api/appkit/Interface.RunEvalOptions", + label: "RunEvalOptions" + }, + { + type: "doc", + id: "api/appkit/Interface.RunEvalsOptions", + label: "RunEvalsOptions" + }, { type: "doc", id: "api/appkit/Interface.Schema", @@ -357,6 +467,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.TelemetryConfig", label: "TelemetryConfig" }, + { + type: "doc", + id: "api/appkit/Interface.TestContext", + label: "TestContext" + }, { type: "doc", id: "api/appkit/Interface.Thread", @@ -458,6 +573,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.DatabaseExports", label: "DatabaseExports" }, + { + type: "doc", + id: "api/appkit/TypeAlias.EvalProgress", + label: "EvalProgress" + }, { type: "doc", id: "api/appkit/TypeAlias.ExecutionResult", @@ -493,6 +613,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.JobsExport", label: "JobsExport" }, + { + type: "doc", + id: "api/appkit/TypeAlias.Matcher", + label: "Matcher" + }, { type: "doc", id: "api/appkit/TypeAlias.PluginData", @@ -528,6 +653,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.ServingFactory", label: "ServingFactory" }, + { + type: "doc", + id: "api/appkit/TypeAlias.Severity", + label: "Severity" + }, { type: "doc", id: "api/appkit/TypeAlias.SupervisorTool", @@ -620,6 +750,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.boolean", label: "boolean" }, + { + type: "doc", + id: "api/appkit/Function.buildAssessments", + label: "buildAssessments" + }, + { + type: "doc", + id: "api/appkit/Function.configureJudge", + label: "configureJudge" + }, { type: "doc", id: "api/appkit/Function.createAgent", @@ -630,6 +770,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.createApp", label: "createApp" }, + { + type: "doc", + id: "api/appkit/Function.createHttpDriver", + label: "createHttpDriver" + }, { type: "doc", id: "api/appkit/Function.createLakebasePool", @@ -650,6 +795,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.database", label: "database" }, + { + type: "doc", + id: "api/appkit/Function.defineEval", + label: "defineEval" + }, { type: "doc", id: "api/appkit/Function.defineManifest", @@ -665,11 +815,26 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.defineTool", label: "defineTool" }, + { + type: "doc", + id: "api/appkit/Function.discoverEvalFiles", + label: "discoverEvalFiles" + }, { type: "doc", id: "api/appkit/Function.enumColumn", label: "enumColumn" }, + { + type: "doc", + id: "api/appkit/Function.equals", + label: "equals" + }, + { + type: "doc", + id: "api/appkit/Function.evalGlyph", + label: "evalGlyph" + }, { type: "doc", id: "api/appkit/Function.executeFromRegistry", @@ -690,6 +855,26 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.fk", label: "fk" }, + { + type: "doc", + id: "api/appkit/Function.formatEvalDetail", + label: "formatEvalDetail" + }, + { + type: "doc", + id: "api/appkit/Function.formatEvalHeadline", + label: "formatEvalHeadline" + }, + { + type: "doc", + id: "api/appkit/Function.formatEvalResults", + label: "formatEvalResults" + }, + { + type: "doc", + id: "api/appkit/Function.formatSummaryLine", + label: "formatSummaryLine" + }, { type: "doc", id: "api/appkit/Function.fromSupervisorApi", @@ -745,6 +930,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.id", label: "id" }, + { + type: "doc", + id: "api/appkit/Function.includes", + label: "includes" + }, { type: "doc", id: "api/appkit/Function.integer", @@ -760,6 +950,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.isHostedTool", label: "isHostedTool" }, + { + type: "doc", + id: "api/appkit/Function.isJudgeConfigured", + label: "isJudgeConfigured" + }, { type: "doc", id: "api/appkit/Function.isSQLTypeMarker", @@ -790,16 +985,36 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.loadAgentsFromDir", label: "loadAgentsFromDir" }, + { + type: "doc", + id: "api/appkit/Function.matches", + label: "matches" + }, { type: "doc", id: "api/appkit/Function.mcpServer", label: "mcpServer" }, + { + type: "doc", + id: "api/appkit/Function.normalizeHost", + label: "normalizeHost" + }, { type: "doc", id: "api/appkit/Function.parseTextToolCalls", label: "parseTextToolCalls" }, + { + type: "doc", + id: "api/appkit/Function.reportToMlflow", + label: "reportToMlflow" + }, + { + type: "doc", + id: "api/appkit/Function.resolveDatabricksAuth", + label: "resolveDatabricksAuth" + }, { type: "doc", id: "api/appkit/Function.resolveHostedTools", @@ -810,6 +1025,21 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.runAgent", label: "runAgent" }, + { + type: "doc", + id: "api/appkit/Function.runEval", + label: "runEval" + }, + { + type: "doc", + id: "api/appkit/Function.runEvalsInDir", + label: "runEvalsInDir" + }, + { + type: "doc", + id: "api/appkit/Function.summarize", + label: "summarize" + }, { type: "doc", id: "api/appkit/Function.text", diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 07a453765..09a4ab695 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -90,6 +90,7 @@ "@opentelemetry/semantic-conventions": "1.38.0", "@types/semver": "7.7.1", "apache-arrow": "21.1.0", + "autoevals": "0.3.0", "dotenv": "16.6.1", "drizzle-orm": "0.45.2", "express": "4.22.2", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 4de7ba79c..b7c457cae 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -83,6 +83,8 @@ export { varchar, } from "./database/schema-builder"; +// Agent evaluation (eve-style authoring, reports to MLflow) +export * from "./evals"; // Agent types export type { AgentDefinition, diff --git a/packages/appkit/src/connectors/index.ts b/packages/appkit/src/connectors/index.ts index 438d334af..714871e67 100644 --- a/packages/appkit/src/connectors/index.ts +++ b/packages/appkit/src/connectors/index.ts @@ -4,4 +4,5 @@ export * from "./genie"; export * from "./jobs"; export * from "./lakebase"; export * from "./mcp"; +export * from "./mlflow"; export * from "./sql-warehouse"; diff --git a/packages/appkit/src/connectors/mlflow/auth.ts b/packages/appkit/src/connectors/mlflow/auth.ts new file mode 100644 index 000000000..4726c5235 --- /dev/null +++ b/packages/appkit/src/connectors/mlflow/auth.ts @@ -0,0 +1,70 @@ +import { createWorkspaceClient } from "../../workspace-client"; + +/** Resolved Databricks host + bearer token for the eval runner's REST calls. */ +export interface DatabricksAuth { + host: string; + token: string; +} + +export interface ResolveDatabricksAuthOptions { + /** `~/.databrickscfg` profile to authenticate with (e.g. `dogfood`). */ + profile?: string; + /** Explicit host; wins over the profile/SDK-resolved host when set. */ + host?: string; + /** Explicit bearer token; when set, no OAuth is minted (PAT/CI path). */ + token?: string; +} + +/** + * Resolve `{host, token}` for the eval runner the same way the rest of AppKit + * authenticates: construct a Databricks `WorkspaceClient` and let its config + * mint (and later refresh) an OAuth bearer from the CLI profile — no hand-set + * PAT required. An explicit host/token still wins (PAT or CI env), so the SDK + * is only consulted for whatever isn't supplied. + * + * Returns `undefined` when neither an explicit token nor a resolvable profile + * yields a bearer, so the caller can treat auth as simply unavailable. + */ +/** Pull the bearer out of an `Authorization: Bearer ` header. */ +function extractBearer(headers: Headers): string | undefined { + return headers.get("authorization")?.replace(/^Bearer\s+/i, ""); +} + +/** + * Resolve `{host, token}` via the SDK: construct a `WorkspaceClient`, let it + * mint/refresh an OAuth bearer from the profile (or reuse a PAT), and fall back + * to any explicit host/token the caller supplied. Returns `undefined` when + * either is missing or the SDK can't resolve credentials. + */ +async function resolveViaSdk( + options: ResolveDatabricksAuthOptions, +): Promise { + try { + const client = createWorkspaceClient( + options.profile ? { profile: options.profile } : {}, + ); + const headers = new Headers(); + // Mints the OAuth access token (or reuses a PAT from the profile) and adds + // an `Authorization: Bearer ` header — the same call the connectors + // use before each request. + await client.config.authenticate(headers); + const token = options.token ?? extractBearer(headers); + const host = + options.host ?? + (await client.config.getHost()).toString().replace(/\/+$/, ""); + if (!token || !host) return undefined; + return { host, token }; + } catch { + return undefined; + } +} + +export async function resolveDatabricksAuth( + options: ResolveDatabricksAuthOptions = {}, +): Promise { + // Fully explicit — no need to touch the SDK. + if (options.host && options.token) { + return { host: options.host, token: options.token }; + } + return resolveViaSdk(options); +} diff --git a/packages/appkit/src/connectors/mlflow/client.ts b/packages/appkit/src/connectors/mlflow/client.ts new file mode 100644 index 000000000..a56545bf1 --- /dev/null +++ b/packages/appkit/src/connectors/mlflow/client.ts @@ -0,0 +1,98 @@ +/** Shared client for talking to the Databricks/MLflow REST API. */ + +/** Ensure the host has a scheme (Databricks env often lacks `https://`). */ +export function normalizeHost(raw: string): string { + const h = raw.trim().replace(/\/+$/, ""); + return /^https?:\/\//i.test(h) ? h : `https://${h}`; +} + +/** Structured result for a best-effort POST that must not throw. */ +export interface PostResult { + ok: boolean; + status?: number; + error?: string; +} + +/** + * A thin client over the Databricks workspace REST API, owning the host + bearer + * token so callers (eval-run creation, assessment writes, the judge's serving + * endpoint) don't each re-derive URLs or re-attach auth. The host is normalized + * once at construction. + */ +export class MlflowClient { + /** Normalized workspace base URL (scheme guaranteed, no trailing slash). */ + readonly baseUrl: string; + private readonly token: string; + + constructor(host: string, token: string) { + this.baseUrl = normalizeHost(host); + this.token = token; + } + + private headers(): Record { + return { + "content-type": "application/json", + authorization: `Bearer ${this.token}`, + }; + } + + /** + * POST JSON to an MLflow REST endpoint. Returns the parsed JSON body, or + * throws with the status + response text so callers can surface a precise + * error. Use for calls whose failure should abort (e.g. `runs/create`). + * + * The thrown message embeds up to 500 chars of the upstream response body to + * aid debugging. That is fine for the dev-facing eval CLI, but do NOT relay + * it into an end-user HTTP response if this client is reused in a request + * handler — the body can carry workspace-internal detail. + */ + async post(path: string, body: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`${path} -> ${res.status} ${text.slice(0, 500)}`); + } + const text = await res.text(); + return (text ? JSON.parse(text) : {}) as T; + } + + /** + * POST JSON without throwing: returns `{ ok, status, error }` so best-effort + * writes (e.g. per-trace assessments) can be collected and reported without + * aborting the run. + * + * `error` embeds up to 500 chars of the upstream body — same caveat as + * {@link post}: fine to log for the dev CLI, don't relay it to end users. + */ + async postResult(path: string, body: unknown): Promise { + try { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + return { ok: false, status: res.status, error: text.slice(0, 500) }; + } + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + /** + * OpenAI-compatible base URL for Databricks Model Serving, used as the judge's + * `OPENAI_BASE_URL`. Same workspace host + token as the MLflow REST calls. + */ + servingEndpointsUrl(): string { + return `${this.baseUrl}/serving-endpoints`; + } +} diff --git a/packages/appkit/src/connectors/mlflow/index.ts b/packages/appkit/src/connectors/mlflow/index.ts new file mode 100644 index 000000000..62506288b --- /dev/null +++ b/packages/appkit/src/connectors/mlflow/index.ts @@ -0,0 +1,6 @@ +export { + type DatabricksAuth, + type ResolveDatabricksAuthOptions, + resolveDatabricksAuth, +} from "./auth"; +export { MlflowClient, normalizeHost, type PostResult } from "./client"; diff --git a/packages/appkit/src/connectors/mlflow/tests/client.test.ts b/packages/appkit/src/connectors/mlflow/tests/client.test.ts new file mode 100644 index 000000000..2e44ea132 --- /dev/null +++ b/packages/appkit/src/connectors/mlflow/tests/client.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { MlflowClient, normalizeHost } from "../client"; + +describe("normalizeHost", () => { + test("adds https:// when missing and strips trailing slashes", () => { + expect(normalizeHost("workspace.cloud.databricks.com")).toBe( + "https://workspace.cloud.databricks.com", + ); + expect(normalizeHost("https://host.com/")).toBe("https://host.com"); + expect(normalizeHost("http://host.com//")).toBe("http://host.com"); + }); +}); + +describe("MlflowClient", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("normalizes host once and derives the serving-endpoints URL", () => { + const client = new MlflowClient("host.databricks.com", "tok"); + expect(client.baseUrl).toBe("https://host.databricks.com"); + expect(client.servingEndpointsUrl()).toBe( + "https://host.databricks.com/serving-endpoints", + ); + }); + + test("post() sends bearer auth to the normalized URL and parses JSON", async () => { + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => + new Response(JSON.stringify({ ok: 1 })), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new MlflowClient("host.com", "secret"); + const body = await client.post("/api/2.0/mlflow/runs/create", { a: 1 }); + + expect(body).toEqual({ ok: 1 }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://host.com/api/2.0/mlflow/runs/create"); + expect(init.method).toBe("POST"); + expect((init.headers as Record).authorization).toBe( + "Bearer secret", + ); + }); + + test("post() throws with status + body on a non-2xx response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("boom", { status: 400 })), + ); + const client = new MlflowClient("host.com", "t"); + await expect(client.post("/p", {})).rejects.toThrow(/400 boom/); + }); + + test("postResult() returns a structured failure instead of throwing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("nope", { status: 403 })), + ); + const client = new MlflowClient("host.com", "t"); + expect(await client.postResult("/p", {})).toEqual({ + ok: false, + status: 403, + error: "nope", + }); + }); + + test("postResult() reports ok on success and network errors without throwing", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockRejectedValueOnce(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const client = new MlflowClient("host.com", "t"); + expect(await client.postResult("/p", {})).toEqual({ ok: true }); + expect(await client.postResult("/p", {})).toEqual({ + ok: false, + error: "ECONNREFUSED", + }); + }); +}); diff --git a/packages/appkit/src/evals/define-eval.ts b/packages/appkit/src/evals/define-eval.ts new file mode 100644 index 000000000..3e31cb191 --- /dev/null +++ b/packages/appkit/src/evals/define-eval.ts @@ -0,0 +1,27 @@ +import type { EvalDefinition } from "./types"; + +/** + * Define an agent eval. Default-export the result from a + * `server/agents//evals/*.eval.ts` file. + * + * @example + * ```ts + * import { defineEval, includes } from "@databricks/appkit/beta"; + * + * export default defineEval({ + * description: "Weather agent basic coverage", + * async test(t) { + * await t.send("What's the weather in Brooklyn?"); + * t.succeeded(); + * t.calledTool("get_weather"); + * t.check(t.reply, includes("Sunny")); + * }, + * }); + * ``` + */ +export function defineEval(def: EvalDefinition): EvalDefinition { + if (typeof def.test !== "function") { + throw new Error("defineEval: `test` must be a function"); + } + return def; +} diff --git a/packages/appkit/src/evals/discover.ts b/packages/appkit/src/evals/discover.ts new file mode 100644 index 000000000..47029c3ad --- /dev/null +++ b/packages/appkit/src/evals/discover.ts @@ -0,0 +1,60 @@ +import { type Dirent, readdirSync } from "node:fs"; +import path from "node:path"; + +import { agentDirNames } from "../core/agent/agent-dirs"; +import { CODE_AGENTS_SOURCE_DIR } from "../core/agent/load-code-agents"; + +/** An eval file found under `server/agents//evals/`. */ +export interface DiscoveredEval { + /** Absolute path to the `*.eval.ts` file. */ + file: string; + /** Id relative to the agent's evals dir, without `.eval.ts` (e.g. `weather/basic`). */ + id: string; + /** The agent id (the `server/agents/` directory name). */ + agent: string; +} + +/** Recursively collect `*.eval.ts` files under `dir`. Empty when `dir` is absent. */ +function evalFilesIn(dir: string): string[] { + try { + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith(".eval.ts")) + .map((e) => path.join(e.parentPath, e.name)); + } catch { + return []; + } +} + +/** + * Discover evals under `/server/agents//evals/` — co-located + * with each agent's `agent.{md,ts}` (same folder-per-agent layout the agents + * plugin discovers). The agent id is the folder name; the eval id is the file + * path relative to that evals dir with `.eval.ts` stripped. Sorted + stable. + */ +export function discoverEvalFiles(rootDir: string): DiscoveredEval[] { + const agentsDir = path.join(rootDir, CODE_AGENTS_SOURCE_DIR); + const out: DiscoveredEval[] = []; + + let entries: Dirent[]; + try { + entries = readdirSync(agentsDir, { withFileTypes: true }); + } catch { + return out; + } + + for (const agent of agentDirNames(entries)) { + const evalsDir = path.join(agentsDir, agent, "evals"); + for (const file of evalFilesIn(evalsDir)) { + const id = path + .relative(evalsDir, file) + .replace(/\.eval\.ts$/, "") + .split(path.sep) + .join("/"); + out.push({ file, id, agent }); + } + } + + return out.sort( + (a, b) => a.agent.localeCompare(b.agent) || a.id.localeCompare(b.id), + ); +} diff --git a/packages/appkit/src/evals/http-driver.ts b/packages/appkit/src/evals/http-driver.ts new file mode 100644 index 000000000..dcc15aba5 --- /dev/null +++ b/packages/appkit/src/evals/http-driver.ts @@ -0,0 +1,202 @@ +import { readSseEvents } from "../stream/sse-reader"; +import type { DriveResult, EvalDriver } from "./types"; + +export interface HttpDriverOptions { + /** Base URL of the running app, e.g. `http://localhost:3000`. */ + baseUrl: string; + /** Agent alias to target. Omit to use the app's default agent. */ + agent?: string; + /** Extra request headers (e.g. auth for a deployed app). */ + headers?: Record; + /** Chat endpoint path. Defaults to `/api/agents/chat`. */ + path?: string; + /** MLflow run id to link each turn's trace to (for evaluation runs). */ + mlflowRunId?: string; + /** + * Max wall-clock time for a single turn before it is abandoned as a failed + * turn (`succeeded: false`). Without this a hung agent — a blocked tool, a + * stalled model — never ends the SSE stream (heartbeats keep it alive), so + * the read loop spins forever and wedges the whole sequential suite. + * Defaults to 120s. + */ + // ponytail: total per-turn cap; switch to an idle timeout if long legit turns get killed. + timeoutMs?: number; +} + +/** Mutable running totals accumulated while draining one turn's SSE stream. */ +interface DriveState { + reply: string; + toolCalls: string[]; + seen: Set; + ok: boolean; + traceId?: string; +} + +/** Record a `function_call` output item once per call id (deduped). */ +function recordToolCall( + item: { type?: string; name?: string; call_id?: string } | undefined, + state: DriveState, +): void { + if (item?.type !== "function_call" || !item.name) return; + const key = item.call_id ?? item.name; + if (state.seen.has(key)) return; + state.seen.add(key); + state.toolCalls.push(item.name); +} + +/** Apply an `appkit.metadata` event's thread/trace ids. */ +function applyMetadata( + data: { threadId?: string; mlflowTraceId?: string } | undefined, + state: DriveState, + setThread: (id: string) => void, +): void { + if (data?.threadId) setThread(data.threadId); + if (data?.mlflowTraceId) state.traceId = data.mlflowTraceId; +} + +/** Parse a single Responses-API SSE `data:` payload into the running totals. */ +function applyEvent( + event: Record, + state: DriveState, + setThread: (id: string) => void, +): void { + const type = event.type; + if (type === "response.output_text.delta") { + if (typeof event.delta === "string") state.reply += event.delta; + return; + } + if ( + type === "response.output_item.added" || + type === "response.output_item.done" + ) { + const item = event.item as { + type?: string; + name?: string; + call_id?: string; + content?: Array<{ text?: string }>; + }; + recordToolCall(item, state); + // A terminal `message` item carries the full reply text and *replaces* + // the accumulated deltas — the server emits no delta for a full message + // (event-translator `handleFullMessage`), so a non-streaming adapter's + // whole reply arrives only here. Guard on non-empty so a streaming turn's + // trailing done event can't clobber the deltas with "". + if (type === "response.output_item.done" && item.type === "message") { + const text = (item.content ?? []).map((c) => c.text ?? "").join(""); + if (text) state.reply = text; + } + return; + } + if (type === "error" || type === "response.failed") { + state.ok = false; + return; + } + if (type === "appkit.metadata") { + applyMetadata( + event.data as { threadId?: string; mlflowTraceId?: string }, + state, + setThread, + ); + } +} + +/** Build the chat request payload, including only the optional fields that are set. */ +function buildRequestBody( + message: string, + options: HttpDriverOptions, + threadId: string | undefined, +): string { + return JSON.stringify({ + message, + ...(options.agent ? { agent: options.agent } : {}), + ...(threadId ? { threadId } : {}), + ...(options.mlflowRunId ? { mlflowRunId: options.mlflowRunId } : {}), + }); +} + +/** + * Drives an agent by POSTing to a running app's chat endpoint and parsing the + * SSE response. Keeps the thread id across `send`s so multi-turn evals share a + * conversation. Agent/stream errors surface as `succeeded: false` rather than + * throwing, so `t.succeeded()` can assert on them. + */ +export function createHttpDriver(options: HttpDriverOptions): EvalDriver { + const chatPath = options.path ?? "/api/agents/chat"; + const timeoutMs = options.timeoutMs ?? 120_000; + let threadId: string | undefined; + + return { + async send(message: string): Promise { + // Bounds connect + the entire read below. Passed to both the fetch and + // the SSE reader: on expiry the reader is cancelled and the turn fails. + const signal = AbortSignal.timeout(timeoutMs); + let res: Response; + try { + res = await fetch(`${options.baseUrl}${chatPath}`, { + method: "POST", + headers: { "content-type": "application/json", ...options.headers }, + body: buildRequestBody(message, options, threadId), + // A chat endpoint never needs a redirect; following one would replay + // custom auth headers (`options.headers`) to the redirect target. + // A 3xx becomes an opaque response (res.ok === false), handled as a + // failed turn by the !res.ok guard below. + redirect: "manual", + signal, + }); + } catch { + return { reply: "", toolCalls: [], succeeded: false }; + } + + if (!res.ok || !res.body) { + return { + reply: "", + toolCalls: [], + succeeded: false, + sessionId: threadId, + }; + } + + const state: DriveState = { + reply: "", + toolCalls: [], + seen: new Set(), + ok: true, + }; + const setThread = (id: string) => { + threadId = id; + }; + try { + for await (const { event, data } of readSseEvents(res.body, signal)) { + if (data === "[DONE]") continue; + // A stream-level error frame (a thrown exception in the generator) + // is framed as `event: error` with a payload that carries no `type`, + // so the SSE event name — not the payload — is the real signal. + if (event === "error") state.ok = false; + try { + applyEvent( + JSON.parse(data) as Record, + state, + setThread, + ); + } catch { + // skip malformed event payloads + } + } + } catch { + // mid-stream transport error or DoS-guard throw: a broken turn. + state.ok = false; + } + // A timeout ends the iterator cleanly (reader cancel) rather than + // throwing, so mark an aborted turn failed explicitly. + if (signal.aborted) state.ok = false; + + return { + reply: state.reply, + toolCalls: state.toolCalls, + succeeded: state.ok, + sessionId: threadId, + traceId: state.traceId, + }; + }, + }; +} diff --git a/packages/appkit/src/evals/index.ts b/packages/appkit/src/evals/index.ts new file mode 100644 index 000000000..92b1df702 --- /dev/null +++ b/packages/appkit/src/evals/index.ts @@ -0,0 +1,53 @@ +export { + type DatabricksAuth, + MlflowClient, + normalizeHost, + type PostResult, + type ResolveDatabricksAuthOptions, + resolveDatabricksAuth, +} from "../connectors/mlflow"; +export { defineEval } from "./define-eval"; +export { type DiscoveredEval, discoverEvalFiles } from "./discover"; +export { createHttpDriver, type HttpDriverOptions } from "./http-driver"; +export { + configureJudge, + isJudgeConfigured, + type JudgeConfig, + type JudgeScore, +} from "./judge"; +export { equals, includes, matches } from "./matchers"; +export { + type Assessment, + buildAssessments, + type ReportOutcome, + reportToMlflow, +} from "./mlflow-report"; +export { + type EvalSummary, + evalGlyph, + formatEvalDetail, + formatEvalHeadline, + formatEvalResults, + formatSummaryLine, + summarize, +} from "./report"; +export { type RunEvalOptions, runEval } from "./run-eval"; +export { + type EvalProgress, + type EvalRunSummary, + type RunEvalsOptions, + runEvalsInDir, +} from "./run-evals"; +export type { + AssertionHandle, + AssertionResult, + CustomJudgeSpec, + DriveResult, + EvalDefinition, + EvalDriver, + EvalResult, + Matcher, + MatchResult, + Severity, + TestContext, +} from "./types"; diff --git a/packages/appkit/src/evals/judge.ts b/packages/appkit/src/evals/judge.ts new file mode 100644 index 000000000..3d5872271 --- /dev/null +++ b/packages/appkit/src/evals/judge.ts @@ -0,0 +1,136 @@ +import type { MlflowClient } from "../connectors/mlflow"; + +/** + * LLM-as-judge scoring via the `autoevals` library (the same scorers eve uses), + * pointed at a Databricks serving endpoint. autoevals talks to an + * OpenAI-compatible API; Databricks Model Serving exposes one at + * `/serving-endpoints`, so we set `OPENAI_BASE_URL`/`OPENAI_API_KEY` and + * use the judge endpoint name as the model. + * + * There is no public REST to call Databricks' built-in judges directly (they're + * Python/SDK-only and the rubric prompts live in the mlflow package), so we run + * autoevals' equivalent scorers against a Databricks judge model. + */ +type AutoEvals = typeof import("autoevals"); + +let mod: AutoEvals | undefined; +let enabled = false; +// Whether configureJudge overwrote the OPENAI_* env vars and they still need +// restoring, plus the values to restore them to. +let configured = false; +let prevBaseUrl: string | undefined; +let prevApiKey: string | undefined; + +export interface JudgeConfig { + /** Client for the workspace hosting the judge serving endpoint. */ + client: MlflowClient; + /** Bearer token for the serving endpoint. */ + token: string; + /** Serving endpoint name used as the judge model. */ + model: string; +} + +/** A normalized judge result. `score` is 0..1. */ +export interface JudgeScore { + score: number; + rationale?: string; +} + +/** + * Configure the judge once. Sets the OpenAI-compatible client env autoevals + * reads and the default judge model. No-op-safe: on failure, judging stays + * disabled and {@link isJudgeConfigured} returns false. + */ +export async function configureJudge(config: JudgeConfig): Promise { + try { + mod = await import("autoevals"); + prevBaseUrl = process.env.OPENAI_BASE_URL; + prevApiKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_BASE_URL = config.client.servingEndpointsUrl(); + process.env.OPENAI_API_KEY = config.token; + configured = true; + mod.init({ defaultModel: config.model }); + enabled = true; + } catch { + enabled = false; + } +} + +export function isJudgeConfigured(): boolean { + return enabled; +} + +/** + * Restore the `OPENAI_*` env vars {@link configureJudge} set, so the judge + * bearer doesn't linger in `process.env` (readable by any imported eval code) + * after the run. Call once the run is done; safe when the judge was never + * configured. Also disables judging so a late `t.judge.*` call fails cleanly + * rather than hitting a torn-down client. + */ +export function teardownJudge(): void { + if (!configured) return; + restoreEnv("OPENAI_BASE_URL", prevBaseUrl); + restoreEnv("OPENAI_API_KEY", prevApiKey); + configured = false; + enabled = false; +} + +function restoreEnv(key: string, prev: string | undefined): void { + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; +} + +/** Normalize an autoevals `Score` into a `JudgeScore`. */ +export function toJudgeScore(s: { + score?: number | null; + metadata?: Record; +}): JudgeScore { + const rationale = s.metadata?.rationale; + return { + score: typeof s.score === "number" ? s.score : 0, + rationale: typeof rationale === "string" ? rationale : undefined, + }; +} + +function ensure(): AutoEvals { + if (!enabled || !mod) { + throw new Error( + "LLM judge is not configured. Pass --judge-model and authenticate via --profile (or DATABRICKS_HOST/DATABRICKS_TOKEN) to use t.judge.*", + ); + } + return mod; +} + +/** Factuality of `output` vs an `expected` reference. */ +export async function judgeFactuality(args: { + input: string; + output: string; + expected: string; +}): Promise { + return toJudgeScore(await ensure().Factuality(args)); +} + +/** Whether `output` answers the question in `input`, optionally constrained by `criteria`. */ +export async function judgeClosedQA(args: { + input: string; + output: string; + criteria: string; +}): Promise { + return toJudgeScore(await ensure().ClosedQA(args)); +} + +/** + * A custom LLM judge defined by a prompt template + choice→score map — the + * TypeScript analog of MLflow's custom `@scorer`. + */ +export async function judgeCustom( + spec: { + name: string; + promptTemplate: string; + choiceScores: Record; + }, + args: { input: string; output: string }, +): Promise { + const scorer = ensure().LLMClassifierFromTemplate(spec); + return toJudgeScore(await scorer(args)); +} diff --git a/packages/appkit/src/evals/matchers.ts b/packages/appkit/src/evals/matchers.ts new file mode 100644 index 000000000..1e57796f9 --- /dev/null +++ b/packages/appkit/src/evals/matchers.ts @@ -0,0 +1,25 @@ +import type { Matcher } from "./types"; + +/** Passes when the value contains `substring`. */ +export function includes(substring: string): Matcher { + return (value) => ({ + pass: value.includes(substring), + detail: `expected to include ${JSON.stringify(substring)}`, + }); +} + +/** Passes when the value equals `expected` exactly. */ +export function equals(expected: string): Matcher { + return (value) => ({ + pass: value === expected, + detail: `expected to equal ${JSON.stringify(expected)}`, + }); +} + +/** Passes when the value matches `pattern`. */ +export function matches(pattern: RegExp): Matcher { + return (value) => ({ + pass: pattern.test(value), + detail: `expected to match ${pattern}`, + }); +} diff --git a/packages/appkit/src/evals/mlflow-report.ts b/packages/appkit/src/evals/mlflow-report.ts new file mode 100644 index 000000000..b18f6df1a --- /dev/null +++ b/packages/appkit/src/evals/mlflow-report.ts @@ -0,0 +1,223 @@ +import type { MlflowClient, PostResult } from "../connectors/mlflow"; +import { mapPool } from "./pool"; +import type { AssertionResult, EvalResult } from "./types"; + +/** + * Max assessment writes in flight. Independent per-trace REST POSTs to the + * Databricks MLflow API; bounded to stay well under its rate limits. + */ +const ASSESSMENT_WRITE_CONCURRENCY = 8; + +/** + * Retry budget for a 404 on an assessment write. The app exports each turn's + * trace asynchronously, so a write issued right after the run can beat the + * trace into the store (404 "trace not found"); linear backoff rides out that + * ingestion lag. Only 404s retry — a 4xx/5xx won't resolve by waiting. + */ +const ASSESSMENT_WRITE_RETRIES = 5; +const ASSESSMENT_RETRY_BASE_MS = 500; +const TRACE_NOT_FOUND = 404; + +/** A Feedback assessment in the MLflow REST proto-JSON shape. */ +export interface Assessment { + trace_id: string; + assessment_name: string; + source: { source_type: "CODE" | "HUMAN" | "LLM_JUDGE"; source_id: string }; + feedback: { value: unknown }; + rationale?: string; + metadata?: Record; +} + +export interface ReportOutcome { + written: number; + skipped: number; + failures: Array<{ traceId: string; status?: number; error?: string }>; +} + +/** + * MLflow assessment names reject `.` (and we avoid spaces/parens too), so map + * anything outside `[A-Za-z0-9_-]` to `_`. The judge check on the raw label + * (`judge.`-prefixed) is unaffected — it runs before sanitization. + */ +function sanitizeName(label: string): string { + return label.replace(/[^A-Za-z0-9_-]/g, "_"); +} + +/** + * Build the Feedback assessments for an eval result: one per assertion (judge + * assertions tagged `LLM_JUDGE` with their numeric score + rationale, so they + * render as judge feedback in MLflow) plus an overall `appkit_eval` pass/fail. + * Returns [] when there's no trace to attach to or the eval was skipped. + */ +/** One Feedback assessment for a single assertion (judge assertions tagged `LLM_JUDGE`). */ +function assertionAssessment( + a: AssertionResult, + traceId: string, + name: string, + evalId: string, +): Assessment { + const isJudge = a.label.startsWith("judge."); + return { + trace_id: traceId, + assessment_name: name, + source: isJudge + ? { source_type: "LLM_JUDGE", source_id: "appkit-judge" } + : { source_type: "CODE", source_id: "appkit-eval" }, + // Judges report a numeric score; deterministic assertions a boolean. + feedback: { value: a.score ?? a.pass }, + rationale: a.detail, + metadata: { eval_id: evalId, severity: a.severity }, + }; +} + +/** The overall `appkit_eval` pass/fail assessment for an eval result. */ +function overallAssessment(result: EvalResult, traceId: string): Assessment { + return { + trace_id: traceId, + assessment_name: "appkit_eval", + source: { source_type: "CODE", source_id: "appkit-eval" }, + feedback: { value: result.passed }, + // Persist only a generic marker, never `result.error` itself: this rationale + // is POSTed to MLflow and readable by anyone with access to the experiment, + // a broader audience than the runner. The full error stays on the operator + // console (printed via the reporter's per-eval detail line). + rationale: result.error + ? "eval errored" + : result.passed + ? "all gates passed" + : "one or more gates failed", + metadata: { eval_id: result.id }, + }; +} + +export function buildAssessments(result: EvalResult): Assessment[] { + if (!result.traceId || result.skipped) return []; + const traceId = result.traceId; + const out: Assessment[] = []; + const used = new Map(); + + for (const a of result.assertions) { + const base = sanitizeName(a.label); + const seen = used.get(base) ?? 0; + used.set(base, seen + 1); + const name = seen === 0 ? base : `${base}_${seen + 1}`; + out.push(assertionAssessment(a, traceId, name, result.id)); + } + + out.push(overallAssessment(result, traceId)); + return out; +} + +/** + * A Unity Catalog V4 trace id: `trace://`. + * Databricks addresses these with the location and the bare hex as *separate* + * path segments (mirrors MLflow's `parse_trace_id_v4`). + */ +const V4_TRACE_ID = /^trace:\/([^/]+)\/([^/]+)$/; + +/** + * Build the REST request to create one assessment, dispatching on the trace-id + * form: + * - **V4 UC** (`trace://`) → `POST /api/4.0/mlflow/traces/ + * {location}/{hex}/assessments`, body is the bare assessment (the Databricks + * RPC maps `location_id` from the path). This is the path UC-backed + * experiments require — the V3 endpoint 400s on a V4 id. + * - **V3** (`tr-...`, classic experiments) → `POST /api/3.0/mlflow/traces/ + * {trace_id}/assessments`, body wraps the assessment in `{ assessment }`. + */ +function assessmentRequest( + assessment: Assessment, + sqlWarehouseId?: string, +): { + path: string; + body: unknown; +} { + const v4 = V4_TRACE_ID.exec(assessment.trace_id); + if (v4) { + const [, location, id] = v4; + // UC trace assessments are backed by a SQL warehouse; Databricks requires + // its id as a query param (mlflow's `_append_sql_warehouse_id_param`). + const query = sqlWarehouseId + ? `?sql_warehouse_id=${encodeURIComponent(sqlWarehouseId)}` + : ""; + return { + path: `/api/4.0/mlflow/traces/${encodeURIComponent(location)}/${id}/assessments${query}`, + body: assessment, + }; + } + return { + path: `/api/3.0/mlflow/traces/${encodeURIComponent(assessment.trace_id)}/assessments`, + body: { assessment }, + }; +} + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Write one assessment, retrying on a 404 (trace not yet ingested) with linear + * backoff. Returns the final {@link PostResult}; non-404 failures return on the + * first attempt. + */ +async function writeAssessment( + client: MlflowClient, + assessment: Assessment, + sqlWarehouseId?: string, +): Promise { + const { path, body } = assessmentRequest(assessment, sqlWarehouseId); + let res = await client.postResult(path, body); + for ( + let attempt = 1; + attempt <= ASSESSMENT_WRITE_RETRIES && + !res.ok && + res.status === TRACE_NOT_FOUND; + attempt++ + ) { + await sleep(ASSESSMENT_RETRY_BASE_MS * attempt); + res = await client.postResult(path, body); + } + return res; +} + +/** + * Write one pass/fail assessment per eval result to the Databricks MLflow REST + * API. Never throws — failures are collected so the run still reports. + */ +export async function reportToMlflow( + client: MlflowClient, + results: EvalResult[], + sqlWarehouseId?: string, +): Promise { + const outcome: ReportOutcome = { written: 0, skipped: 0, failures: [] }; + + // Build every assessment first (pure, no I/O). `skipped` counts results with + // no trace to attach to; `written`/`failures` are counted per assessment. + const assessments: Assessment[] = []; + for (const result of results) { + const built = buildAssessments(result); + if (built.length === 0) { + outcome.skipped++; + continue; + } + assessments.push(...built); + } + + // The writes are independent per-trace REST calls, so run them through a + // bounded pool instead of strictly serial. postResult never throws. + const posts = await mapPool(assessments, ASSESSMENT_WRITE_CONCURRENCY, (a) => + writeAssessment(client, a, sqlWarehouseId), + ); + for (let i = 0; i < posts.length; i++) { + const res = posts[i]; + if (res.ok) { + outcome.written++; + } else { + outcome.failures.push({ + traceId: assessments[i].trace_id, + status: res.status, + error: res.error, + }); + } + } + return outcome; +} diff --git a/packages/appkit/src/evals/mlflow-run.ts b/packages/appkit/src/evals/mlflow-run.ts new file mode 100644 index 000000000..dadcfc384 --- /dev/null +++ b/packages/appkit/src/evals/mlflow-run.ts @@ -0,0 +1,125 @@ +import type { MlflowClient } from "../connectors/mlflow"; +import type { EvalResult } from "./types"; + +/** Run tag value that makes a run appear under the experiment's "Evaluation runs". */ +const GENAI_EVALUATE_RUN_TYPE = "genai_evaluate"; + +/** + * Source tags on the eval run. Traces linked via `mlflow.sourceRun` surface the + * run's source in the traces-table "Source" column (mirrors how Python's + * `evaluate()` shows the script name), so tagging the run is what populates it. + */ +const EVAL_SOURCE_NAME = "appkit agent eval"; + +interface CreateRunResponse { + run: { info: { run_id?: string; run_uuid?: string } }; +} + +interface MlflowMetric { + key: string; + value: number; + timestamp: number; + step: number; +} + +/** + * Create an MLflow run tagged as a GenAI evaluation, so it shows under the + * experiment's "Evaluation runs". Returns the run id; link traces to it via the + * `mlflow.sourceRun` trace metadata and log results before finishing. + */ +export async function createEvalRun( + client: MlflowClient, + options: { + experimentId: string; + runName?: string; + startTime: number; + }, +): Promise { + const created = await client.post( + "/api/2.0/mlflow/runs/create", + { + experiment_id: options.experimentId, + start_time: options.startTime, + ...(options.runName ? { run_name: options.runName } : {}), + tags: [ + { key: "mlflow.runType", value: GENAI_EVALUATE_RUN_TYPE }, + { key: "mlflow.source.name", value: EVAL_SOURCE_NAME }, + { key: "mlflow.source.type", value: "LOCAL" }, + ], + }, + ); + const runId = created.run?.info?.run_id ?? created.run?.info?.run_uuid; + if (!runId) { + throw new Error("runs/create returned no run id"); + } + return runId; +} + +/** Aggregate per-eval results into MLflow run metrics. */ +export function aggregateMetrics( + results: EvalResult[], + timestamp: number, +): MlflowMetric[] { + const scored = results.filter((r) => !r.skipped); + const passed = scored.filter((r) => r.passed).length; + return [ + { key: "eval/total", value: results.length, timestamp, step: 0 }, + { key: "eval/scored", value: scored.length, timestamp, step: 0 }, + { key: "eval/passed", value: passed, timestamp, step: 0 }, + { + key: "eval/pass_rate", + value: scored.length ? passed / scored.length : 0, + timestamp, + step: 0, + }, + ]; +} + +export interface FinishOutcome { + finished: boolean; + /** Metric logging is best-effort; set when it failed (the run is still finished). */ + metricsError?: string; + /** Set when the FINISHED update itself failed (run may be left RUNNING). */ + finishError?: string; +} + +/** + * Log aggregate metrics (best-effort) and mark the eval run FINISHED. Never + * throws — a metric-logging failure must not prevent the run from being closed, + * or it would be left stuck in RUNNING forever. + */ +export async function finishEvalRun( + client: MlflowClient, + options: { + runId: string; + results: EvalResult[]; + endTime: number; + }, +): Promise { + const outcome: FinishOutcome = { finished: false }; + + const metrics = aggregateMetrics(options.results, options.endTime); + if (metrics.length) { + try { + await client.post("/api/2.0/mlflow/runs/log-batch", { + run_id: options.runId, + metrics, + }); + } catch (err) { + outcome.metricsError = err instanceof Error ? err.message : String(err); + } + } + + try { + await client.post("/api/2.0/mlflow/runs/update", { + run_id: options.runId, + status: "FINISHED", + end_time: options.endTime, + }); + outcome.finished = true; + } catch (err) { + outcome.finishError = err instanceof Error ? err.message : String(err); + } + + return outcome; +} diff --git a/packages/appkit/src/evals/pool.ts b/packages/appkit/src/evals/pool.ts new file mode 100644 index 000000000..e48262e7d --- /dev/null +++ b/packages/appkit/src/evals/pool.ts @@ -0,0 +1,30 @@ +/** + * Run `fn` over `items` with at most `concurrency` calls in flight, preserving + * input order in the result array. `concurrency` is clamped to `[1, length]`. + * `fn` must not reject — a rejection abandons the other in-flight items. + */ +export async function mapPool( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + // Coerce a non-finite concurrency (e.g. NaN from a bad CLI `--concurrency`) + // to 1: otherwise Math.min(NaN, len) is NaN, Array.from({length: NaN}) is [], + // and zero workers spawn — silently skipping every item and leaving `undefined` + // holes in the result. + const n = Number.isFinite(concurrency) ? concurrency : 1; + const limit = Math.max(1, Math.min(n, items.length)); + let cursor = 0; + // Each worker pulls the next index off the shared cursor until exhausted. + // `cursor++` is atomic between awaits (single-threaded), so no index is + // handed to two workers. + const worker = async (): Promise => { + while (cursor < items.length) { + const index = cursor++; + results[index] = await fn(items[index], index); + } + }; + await Promise.all(Array.from({ length: limit }, () => worker())); + return results; +} diff --git a/packages/appkit/src/evals/report.ts b/packages/appkit/src/evals/report.ts new file mode 100644 index 000000000..2e42c5b1c --- /dev/null +++ b/packages/appkit/src/evals/report.ts @@ -0,0 +1,76 @@ +import type { EvalResult } from "./types"; + +export interface EvalSummary { + total: number; + passed: number; + failed: number; + skipped: number; + /** True when no eval failed (skips don't count as failures). */ + allPassed: boolean; +} + +export function summarize(results: EvalResult[]): EvalSummary { + let passed = 0; + let failed = 0; + let skipped = 0; + for (const r of results) { + if (r.skipped) skipped++; + else if (r.passed) passed++; + else failed++; + } + return { + total: results.length, + passed, + failed, + skipped, + allPassed: failed === 0, + }; +} + +/** Status glyph for a single eval result. */ +export function evalGlyph(result: EvalResult): string { + if (result.skipped) return "−"; + return result.passed ? "✓" : "✗"; +} + +/** The one-line header for a single eval result (no failure detail). */ +export function formatEvalHeadline(result: EvalResult): string { + if (result.skipped) { + return `− ${result.id} (skipped${ + result.skipped.reason ? `: ${result.skipped.reason}` : "" + })`; + } + return `${evalGlyph(result)} ${result.id}${ + result.description ? ` — ${result.description}` : "" + }`; +} + +/** Indented detail lines for a failing eval (error + failing assertions). */ +export function formatEvalDetail(result: EvalResult): string[] { + const lines: string[] = []; + if (result.error) lines.push(` error: ${result.error}`); + for (const a of result.assertions) { + if (a.pass) continue; + const tag = a.severity === "soft" ? "soft" : "gate"; + lines.push(` ✗ [${tag}] ${a.label}${a.detail ? ` — ${a.detail}` : ""}`); + } + return lines; +} + +/** The final PASS/FAIL summary line. */ +export function formatSummaryLine(results: EvalResult[]): string { + const s = summarize(results); + return `${s.allPassed ? "PASS" : "FAIL"} — ${s.passed} passed, ${s.failed} failed, ${s.skipped} skipped (${s.total} total)`; +} + +/** Render all results as a human-readable console report (non-streaming). */ +export function formatEvalResults(results: EvalResult[]): string { + const lines: string[] = []; + for (const r of results) { + lines.push(formatEvalHeadline(r)); + lines.push(...formatEvalDetail(r)); + } + lines.push(""); + lines.push(formatSummaryLine(results)); + return lines.join("\n"); +} diff --git a/packages/appkit/src/evals/run-eval.ts b/packages/appkit/src/evals/run-eval.ts new file mode 100644 index 000000000..e227af757 --- /dev/null +++ b/packages/appkit/src/evals/run-eval.ts @@ -0,0 +1,195 @@ +import { judgeClosedQA, judgeCustom, judgeFactuality } from "./judge"; +import type { + AssertionHandle, + AssertionResult, + EvalDefinition, + EvalDriver, + EvalResult, + Matcher, + TestContext, +} from "./types"; + +/** Default pass threshold for an LLM-judge score (0..1) before `.atLeast()`. */ +const DEFAULT_JUDGE_THRESHOLD = 0.5; + +/** Thrown by `t.skip()` to unwind the test and mark the eval skipped. */ +class SkipSignal extends Error { + constructor(public reason?: string) { + super("eval skipped"); + this.name = "SkipSignal"; + } +} + +export interface RunEvalOptions { + /** Stable id for the eval (e.g. its file path relative to the evals dir). */ + id: string; + /** Drives the agent and returns reply/tool-calls/success per `send`. */ + driver: EvalDriver; + /** When true, soft assertion failures also fail the eval. */ + strict?: boolean; +} + +/** + * Run a single eval against a driver. Never throws for assertion or agent + * failures — those become a non-passing {@link EvalResult}. Only a malformed + * eval definition surfaces as `result.error`. + */ +export async function runEval( + def: EvalDefinition, + options: RunEvalOptions, +): Promise { + const assertions: AssertionResult[] = []; + let reply = ""; + let lastInput = ""; + let toolCalls: string[] = []; + let sessionId: string | undefined; + let lastTraceId: string | undefined; + let lastSucceeded = false; + + const record = ( + label: string, + pass: boolean, + score?: number, + detail?: string, + ): AssertionHandle => { + const result: AssertionResult = { + label, + severity: "gate", + pass, + score, + detail, + }; + assertions.push(result); + const handle: AssertionHandle = { + gate() { + result.severity = "gate"; + return handle; + }, + soft() { + result.severity = "soft"; + return handle; + }, + atLeast(threshold: number) { + result.severity = "soft"; + result.pass = (result.score ?? (result.pass ? 1 : 0)) >= threshold; + return handle; + }, + }; + return handle; + }; + + // LLM-judge assertions are scored and soft by default; the caller chains + // `.atLeast(n)` to set the pass threshold or `.gate()` to promote. + const recordJudge = ( + label: string, + score: number, + rationale?: string, + ): AssertionHandle => + record(label, score >= DEFAULT_JUDGE_THRESHOLD, score, rationale).soft(); + + const t: TestContext = { + async send(message) { + lastInput = message; + const r = await options.driver.send(message); + reply = r.reply; + toolCalls = r.toolCalls; + sessionId = r.sessionId; + lastSucceeded = r.succeeded; + if (r.traceId) lastTraceId = r.traceId; + }, + get reply() { + return reply; + }, + get toolCalls() { + return toolCalls; + }, + get sessionId() { + return sessionId; + }, + succeeded() { + return record( + "succeeded", + lastSucceeded, + undefined, + lastSucceeded ? undefined : "agent turn did not complete successfully", + ); + }, + calledTool(name) { + return record( + `calledTool(${name})`, + toolCalls.includes(name), + undefined, + `expected tool "${name}" to be called (called: ${ + toolCalls.length ? toolCalls.join(", ") : "none" + })`, + ); + }, + check(value: string, matcher: Matcher) { + const m = matcher(value); + return record("check", m.pass, m.score, m.detail); + }, + judge: { + async factuality(expected) { + const { score, rationale } = await judgeFactuality({ + input: lastInput, + output: reply, + expected, + }); + return recordJudge("judge.factuality", score, rationale); + }, + async closedQA(criteria) { + const { score, rationale } = await judgeClosedQA({ + input: lastInput, + output: reply, + criteria, + }); + return recordJudge("judge.closedQA", score, rationale); + }, + async custom(spec) { + const { score, rationale } = await judgeCustom(spec, { + input: lastInput, + output: reply, + }); + return recordJudge(`judge.${spec.name}`, score, rationale); + }, + }, + skip(reason) { + throw new SkipSignal(reason); + }, + }; + + try { + await def.test(t); + } catch (err) { + if (err instanceof SkipSignal) { + return { + id: options.id, + description: def.description, + skipped: { reason: err.reason }, + assertions, + passed: true, + traceId: lastTraceId, + }; + } + return { + id: options.id, + description: def.description, + assertions, + passed: false, + error: err instanceof Error ? err.message : String(err), + traceId: lastTraceId, + }; + } + + const passed = assertions.every( + (a) => a.pass || (a.severity === "soft" && !options.strict), + ); + + return { + id: options.id, + description: def.description, + assertions, + passed, + traceId: lastTraceId, + }; +} diff --git a/packages/appkit/src/evals/run-evals.ts b/packages/appkit/src/evals/run-evals.ts new file mode 100644 index 000000000..3070bdbcf --- /dev/null +++ b/packages/appkit/src/evals/run-evals.ts @@ -0,0 +1,261 @@ +import { pathToFileURL } from "node:url"; + +import { MlflowClient } from "../connectors/mlflow"; +import { type DiscoveredEval, discoverEvalFiles } from "./discover"; +import { createHttpDriver } from "./http-driver"; +import { configureJudge, teardownJudge } from "./judge"; +import { type ReportOutcome, reportToMlflow } from "./mlflow-report"; +import { createEvalRun, type FinishOutcome, finishEvalRun } from "./mlflow-run"; +import { mapPool } from "./pool"; +import { runEval } from "./run-eval"; +import type { EvalDefinition, EvalResult } from "./types"; + +export interface RunEvalsOptions { + /** Project root containing `server/agents/`. Defaults to `process.cwd()`. */ + rootDir?: string; + /** Base URL of the running app to drive, e.g. `http://localhost:3000`. */ + baseUrl: string; + /** Substring filter on `/` (or an exact agent id). */ + filter?: string; + /** Soft assertion failures also fail the eval. */ + strict?: boolean; + /** Extra request headers for the driver (e.g. auth for a deployed app). */ + headers?: Record; + /** Per-turn wall-clock timeout (ms) before a turn is failed. Defaults to 120s. */ + timeoutMs?: number; + /** + * Max evals to drive concurrently. Each eval opens one stream to the app as + * the same user, so keep this at or below the app's + * `maxConcurrentStreamsPerUser` (default 5) or the surplus streams hit the + * 429 guard. Defaults to 4; clamped to `[1, total]`. + */ + concurrency?: number; + /** + * When set, create a native MLflow "Evaluation run": each eval's trace is + * linked to the run, pass/fail is written as feedback, and aggregate metrics + * are logged. Requires Databricks creds + the target experiment. + */ + mlflow?: { + host: string; + token: string; + experimentId: string; + /** SQL warehouse id for writing assessments to UC-backed (V4) traces. */ + sqlWarehouseId?: string; + }; + /** + * When set, enable `t.judge.*` LLM-as-judge scoring via autoevals against a + * Databricks serving endpoint (`model`). + */ + judge?: { host: string; token: string; model: string }; + /** Wall-clock timestamp (ms) for run create/finish — pass `Date.now()`. */ + now?: number; + /** Progress callback, invoked as evals are discovered, started, and finished. */ + onEvent?: (event: EvalProgress) => void; +} + +export type EvalProgress = + | { type: "discovered"; total: number } + | { type: "run-created"; runId: string } + | { type: "start"; id: string; index: number; total: number } + | { type: "result"; result: EvalResult; index: number; total: number }; + +export interface EvalRunSummary { + results: EvalResult[]; + /** Present when an MLflow evaluation run was created. */ + mlflow?: { runId: string; report: ReportOutcome; finish: FinishOutcome }; +} + +/** + * Load a `*.eval.ts` file and return its default-exported {@link EvalDefinition}. + * Uses tsx's programmatic loader so TypeScript eval files run without a build + * step. The specifier is indirected so the type checker doesn't try to resolve + * tsx's internal entry. + */ +async function loadEval(file: string): Promise { + const tsxApi = "tsx/esm/api"; + let tsImport: (specifier: string, parentURL: string) => Promise; + try { + ({ tsImport } = (await import(tsxApi)) as { + tsImport: (specifier: string, parentURL: string) => Promise; + }); + } catch { + throw new Error( + "Running .eval.ts files requires `tsx`. Install it as a dev dependency (`pnpm add -D tsx`).", + ); + } + + const mod = await tsImport(pathToFileURL(file).href, import.meta.url); + const def = resolveEvalDefault(mod); + if (!def) { + throw new Error(`${file}: must default-export defineEval({ test })`); + } + return def; +} + +/** + * Unwrap the eval default export across module-interop shapes. Depending on + * whether the eval file is treated as ESM or CJS, the value lands at + * `mod.default` (ESM), `mod.default.default` (CJS `__esModule` double-wrap), or + * `mod` itself. Returns the first candidate that looks like an eval. + */ +export function resolveEvalDefault(mod: unknown): EvalDefinition | undefined { + let candidate: unknown = mod; + for (let i = 0; i < 4 && candidate; i++) { + if (typeof (candidate as EvalDefinition).test === "function") { + return candidate as EvalDefinition; + } + candidate = (candidate as { default?: unknown }).default; + } + return undefined; +} + +/** + * Load and run a single discovered eval. Never throws — a load/run failure + * becomes a non-passing {@link EvalResult} so one bad eval can't abort the run. + */ +async function runOne( + d: DiscoveredEval, + id: string, + runId: string | undefined, + options: RunEvalsOptions, +): Promise { + try { + const def = await loadEval(d.file); + const driver = createHttpDriver({ + baseUrl: options.baseUrl, + agent: def.agent ?? d.agent, + headers: options.headers, + mlflowRunId: runId, + timeoutMs: options.timeoutMs, + }); + return await runEval(def, { id, driver, strict: options.strict }); + } catch (err) { + return { + id, + assertions: [], + passed: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** Configure the LLM judge when judge creds were supplied; otherwise a no-op. */ +async function maybeConfigureJudge(options: RunEvalsOptions): Promise { + if (!options.judge) return; + await configureJudge({ + client: new MlflowClient(options.judge.host, options.judge.token), + token: options.judge.token, + model: options.judge.model, + }); +} + +/** + * Report per-eval assessments and finish the MLflow run, when one was created. + * Returns the run summary, or `undefined` when there was no run to finalize. + */ +async function finalizeMlflow( + client: MlflowClient | undefined, + runId: string | undefined, + results: EvalResult[], + options: RunEvalsOptions, +): Promise { + if (!client || !runId) return undefined; + // reportToMlflow is not supposed to throw, but if it ever does the run must + // still be finished — otherwise it hangs in RUNNING forever. + let report: ReportOutcome = { written: 0, skipped: 0, failures: [] }; + try { + report = await reportToMlflow( + client, + results, + options.mlflow?.sqlWarehouseId, + ); + } catch (err) { + report.failures.push({ + traceId: "(report)", + error: err instanceof Error ? err.message : String(err), + }); + } + const finish = await finishEvalRun(client, { + runId, + results, + endTime: options.now ?? Date.now(), + }); + return { runId, report, finish }; +} + +/** + * Default max evals in flight. Each eval opens one stream to the app as the + * same user; the server caps concurrent streams per user at 5 by default + * (`maxConcurrentStreamsPerUser`), so 4 leaves headroom under that limit. + */ +const DEFAULT_CONCURRENCY = 4; + +/** + * Discover, load, and run every eval under each agent's `evals/` dir, driving + * the agents on a running app. Never throws for an individual eval — load/run + * failures become non-passing {@link EvalResult}s. + */ +export async function runEvalsInDir( + options: RunEvalsOptions, +): Promise { + const root = options.rootDir ?? process.cwd(); + const now = options.now ?? Date.now(); + let discovered = discoverEvalFiles(root); + + if (options.filter) { + const f = options.filter; + discovered = discovered.filter( + (d) => d.agent === f || `${d.agent}/${d.id}`.includes(f), + ); + } + + const emit = options.onEvent ?? (() => {}); + const total = discovered.length; + emit({ type: "discovered", total }); + + // The judge sets OPENAI_* env vars globally (autoevals reads them per call), + // so tear them down in `finally` once the run is over — pass or throw — so + // the bearer doesn't linger in process.env. + await maybeConfigureJudge(options); + try { + // Create the MLflow evaluation run up front so each eval's trace can be + // linked to it as it runs. One client is shared by run create/finish and + // the per-trace assessment writes. + let runId: string | undefined; + let mlflowClient: MlflowClient | undefined; + if (options.mlflow) { + mlflowClient = new MlflowClient( + options.mlflow.host, + options.mlflow.token, + ); + runId = await createEvalRun(mlflowClient, { + experimentId: options.mlflow.experimentId, + runName: `appkit-eval ${new Date(now).toISOString()}`, + startTime: now, + }); + emit({ type: "run-created", runId }); + } + + // Run evals through a bounded pool so independent turns overlap instead of + // summing their latencies. runOne never throws, so a pool worker never + // rejects; results preserve discovery order (mapPool writes by index). + const results = await mapPool( + discovered, + options.concurrency ?? DEFAULT_CONCURRENCY, + async (d, index) => { + const id = `${d.agent}/${d.id}`; + emit({ type: "start", id, index, total }); + const result = await runOne(d, id, runId, options); + emit({ type: "result", result, index, total }); + return result; + }, + ); + + const summary: EvalRunSummary = { results }; + const mlflow = await finalizeMlflow(mlflowClient, runId, results, options); + if (mlflow) summary.mlflow = mlflow; + return summary; + } finally { + teardownJudge(); + } +} diff --git a/packages/appkit/src/evals/tests/discover.test.ts b/packages/appkit/src/evals/tests/discover.test.ts new file mode 100644 index 000000000..e93124d70 --- /dev/null +++ b/packages/appkit/src/evals/tests/discover.test.ts @@ -0,0 +1,44 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { discoverEvalFiles } from "../discover"; + +let root: string; + +function write(rel: string, content = "export default {}") { + const full = path.join(root, rel); + mkdirSync(path.dirname(full), { recursive: true }); + writeFileSync(full, content); +} + +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "appkit-evals-")); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("discoverEvalFiles", () => { + test("finds *.eval.ts per agent, derives id + agent, ignores non-evals", () => { + write("server/agents/support/evals/basic.eval.ts"); + write("server/agents/support/evals/nested/deep.eval.ts"); + write("server/agents/support/evals/evals.config.ts"); + write("server/agents/analyst/evals/sql.eval.ts"); + write("server/agents/no-evals/agent.md", "# agent"); + + const found = discoverEvalFiles(root); + + expect(found.map((f) => `${f.agent}/${f.id}`)).toEqual([ + "analyst/sql", + "support/basic", + "support/nested/deep", + ]); + }); + + test("returns empty when there is no server/agents dir", () => { + expect(discoverEvalFiles(root)).toEqual([]); + }); +}); diff --git a/packages/appkit/src/evals/tests/http-driver.test.ts b/packages/appkit/src/evals/tests/http-driver.test.ts new file mode 100644 index 000000000..99e9a1eed --- /dev/null +++ b/packages/appkit/src/evals/tests/http-driver.test.ts @@ -0,0 +1,145 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { createHttpDriver } from "../http-driver"; + +/** + * A real SSE server, one behavior per path, so the driver's actual read loop + * (heartbeat skipping, event-line parsing, timeout abort) is exercised end to + * end rather than mocked. + */ +const server: Server = createServer((req, res) => { + res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8" }); + + switch (req.url) { + // Happy path: a text delta then a normal end. + case "/ok": + res.write( + `id: 1\nevent: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", + delta: "Hello", + })}\n\n`, + ); + res.write(`data: [DONE]\n\n`); + res.end(); + return; + + // A thrown exception in the generator is framed by SSEWriter.writeError: + // `event: error` with a payload that has NO `type` field. + case "/thrown-error": + res.write( + `id: 1\nevent: error\ndata: ${JSON.stringify({ + error: "Internal server error", + code: "INTERNAL_ERROR", + })}\n\n`, + ); + res.end(); + return; + + // Non-streaming adapter (e.g. LangChain): a full `message` item and NO + // text deltas. The reply must come from the message item's content. + case "/message-only": + res.write( + `event: response.output_item.added\ndata: ${JSON.stringify({ + type: "response.output_item.added", + item: { type: "message", content: [] }, + })}\n\n`, + ); + res.write( + `event: response.output_item.done\ndata: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + content: [{ type: "output_text", text: "the answer" }], + }, + })}\n\n`, + ); + res.write(`data: [DONE]\n\n`); + res.end(); + return; + + // Deltas followed by a terminal `message` that corrects them: the message + // replaces the accumulated deltas rather than appending. + case "/delta-then-message": + res.write( + `event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", + delta: "partial", + })}\n\n`, + ); + res.write( + `event: response.output_item.done\ndata: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + content: [{ type: "output_text", text: "full final content" }], + }, + })}\n\n`, + ); + res.write(`data: [DONE]\n\n`); + res.end(); + return; + + // A hung agent: heartbeat comments keep the socket alive but the stream + // never ends. Deliberately never call res.end(). + default: + res.write(`: heartbeat\n\n`); + return; + } +}); + +let baseUrl = ""; + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +describe("createHttpDriver", () => { + test("captures the reply and succeeds on a normal stream", async () => { + const driver = createHttpDriver({ baseUrl, path: "/ok" }); + const result = await driver.send("hi"); + expect(result.succeeded).toBe(true); + expect(result.reply).toBe("Hello"); + }); + + test("reports succeeded:false on a thrown-error frame (no false PASS)", async () => { + const driver = createHttpDriver({ baseUrl, path: "/thrown-error" }); + const result = await driver.send("hi"); + expect(result.succeeded).toBe(false); + }); + + test("captures reply from a message item when there are no deltas", async () => { + const driver = createHttpDriver({ baseUrl, path: "/message-only" }); + const result = await driver.send("hi"); + expect(result.succeeded).toBe(true); + expect(result.reply).toBe("the answer"); + }); + + test("a terminal message replaces accumulated deltas", async () => { + const driver = createHttpDriver({ baseUrl, path: "/delta-then-message" }); + const result = await driver.send("hi"); + expect(result.succeeded).toBe(true); + expect(result.reply).toBe("full final content"); + }); + + test("times out a hung stream instead of hanging forever", async () => { + const driver = createHttpDriver({ + baseUrl, + path: "/stall", + timeoutMs: 150, + }); + const started = Date.now(); + const result = await driver.send("hi"); + expect(result.succeeded).toBe(false); + expect(Date.now() - started).toBeLessThan(2000); + }); +}); diff --git a/packages/appkit/src/evals/tests/judge.test.ts b/packages/appkit/src/evals/tests/judge.test.ts new file mode 100644 index 000000000..c34ce47a9 --- /dev/null +++ b/packages/appkit/src/evals/tests/judge.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; + +import type { MlflowClient } from "../../connectors/mlflow"; +import { + configureJudge, + isJudgeConfigured, + teardownJudge, + toJudgeScore, +} from "../judge"; + +describe("judge score mapping", () => { + test("toJudgeScore reads score and rationale from an autoevals Score", () => { + expect( + toJudgeScore({ score: 0.8, metadata: { rationale: "mostly correct" } }), + ).toEqual({ score: 0.8, rationale: "mostly correct" }); + }); + + test("toJudgeScore defaults a missing/null score to 0 and omits rationale", () => { + expect(toJudgeScore({ score: null })).toEqual({ score: 0 }); + expect(toJudgeScore({})).toEqual({ score: 0 }); + expect(toJudgeScore({ score: 1, metadata: { rationale: 42 } })).toEqual({ + score: 1, + }); + }); + + test("judging is disabled until configured", () => { + expect(isJudgeConfigured()).toBe(false); + }); +}); + +describe("judge env lifecycle", () => { + test("sets the bearer for the run, then restores it on teardown", async () => { + const before = process.env.OPENAI_API_KEY; + const client = { + servingEndpointsUrl: () => "https://host.example/serving-endpoints", + } as unknown as MlflowClient; + + await configureJudge({ client, token: "secret-bearer", model: "judge" }); + // The bearer must be live in the env during the run (autoevals reads it). + expect(process.env.OPENAI_API_KEY).toBe("secret-bearer"); + + teardownJudge(); + // After the run it must not linger in process.env. + expect(process.env.OPENAI_API_KEY).toBe(before); + expect(isJudgeConfigured()).toBe(false); + }); +}); diff --git a/packages/appkit/src/evals/tests/matchers.test.ts b/packages/appkit/src/evals/tests/matchers.test.ts new file mode 100644 index 000000000..4836a88f6 --- /dev/null +++ b/packages/appkit/src/evals/tests/matchers.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; + +import { equals, includes, matches } from "../matchers"; + +describe("eval matchers", () => { + test("includes", () => { + expect(includes("Sunny")("It is Sunny today").pass).toBe(true); + expect(includes("Rainy")("It is Sunny today").pass).toBe(false); + }); + + test("equals", () => { + expect(equals("yes")("yes").pass).toBe(true); + expect(equals("yes")("Yes").pass).toBe(false); + }); + + test("matches", () => { + expect(matches(/^\d+ rows$/)("42 rows").pass).toBe(true); + expect(matches(/^\d+ rows$/)("forty rows").pass).toBe(false); + }); +}); diff --git a/packages/appkit/src/evals/tests/mlflow-report.test.ts b/packages/appkit/src/evals/tests/mlflow-report.test.ts new file mode 100644 index 000000000..1c9e764c3 --- /dev/null +++ b/packages/appkit/src/evals/tests/mlflow-report.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "vitest"; + +import type { MlflowClient } from "../../connectors/mlflow"; +import { buildAssessments, reportToMlflow } from "../mlflow-report"; +import type { EvalResult } from "../types"; + +describe("buildAssessments", () => { + test("emits one feedback per assertion plus an overall appkit_eval", () => { + const result: EvalResult = { + id: "support/weather", + traceId: "tr-123", + assertions: [ + { label: "succeeded", severity: "gate", pass: true }, + { + label: "judge.closedQA", + severity: "soft", + pass: true, + score: 0.9, + detail: "clearly relevant", + }, + ], + passed: true, + }; + const out = buildAssessments(result); + expect(out.map((a) => a.assessment_name)).toEqual([ + "succeeded", + "judge_closedQA", + "appkit_eval", + ]); + + const judge = out.find((a) => a.assessment_name === "judge_closedQA"); + expect(judge?.source.source_type).toBe("LLM_JUDGE"); + expect(judge?.feedback.value).toBe(0.9); // numeric score, not boolean + expect(judge?.rationale).toBe("clearly relevant"); + + const succeeded = out.find((a) => a.assessment_name === "succeeded"); + expect(succeeded?.source.source_type).toBe("CODE"); + expect(succeeded?.feedback.value).toBe(true); + + const overall = out.find((a) => a.assessment_name === "appkit_eval"); + expect(overall?.feedback.value).toBe(true); + }); + + test("sanitizes and de-duplicates assertion names", () => { + const out = buildAssessments({ + id: "x", + traceId: "tr-1", + assertions: [ + { label: "calledTool(get_weather)", severity: "gate", pass: true }, + { label: "check", severity: "gate", pass: true }, + { label: "check", severity: "gate", pass: true }, + ], + passed: true, + }); + const names = out.map((a) => a.assessment_name); + expect(names).toContain("calledTool_get_weather_"); + expect(names).toContain("check"); + expect(names).toContain("check_2"); + }); + + test("returns [] without a trace id or when skipped", () => { + expect(buildAssessments({ id: "x", assertions: [], passed: true })).toEqual( + [], + ); + expect( + buildAssessments({ + id: "x", + traceId: "tr-1", + assertions: [], + passed: true, + skipped: { reason: "no data" }, + }), + ).toEqual([]); + }); + + test("does not leak result.error into the persisted overall rationale", () => { + // The rationale is POSTed to MLflow and readable by anyone with experiment + // access, so it must carry only a generic marker — never the raw error text. + const out = buildAssessments({ + id: "x", + traceId: "tr-1", + assertions: [], + passed: false, + error: "secret detail: postgres://user:pw@host/db connection failed", + }); + const overall = out.find((a) => a.assessment_name === "appkit_eval"); + expect(overall?.feedback.value).toBe(false); + expect(overall?.rationale).toBe("eval errored"); + expect(overall?.rationale).not.toContain("secret detail"); + }); +}); + +describe("reportToMlflow", () => { + test("retries an assessment write on 404 until the trace is ingested", async () => { + let calls = 0; + const client = { + // The first write 404s (the trace hasn't been ingested yet), then succeeds. + postResult: async () => { + calls++; + return calls === 1 + ? { ok: false, status: 404, error: "not found" } + : { ok: true }; + }, + } as unknown as MlflowClient; + + const result: EvalResult = { + id: "query/sum", + traceId: "tr-abc", + assertions: [{ label: "reply", severity: "gate", pass: true }], + passed: true, + }; + + const outcome = await reportToMlflow(client, [result]); + // assertion + overall, both written after the one retry — no failures. + expect(outcome.written).toBe(2); + expect(outcome.failures).toEqual([]); + expect(calls).toBe(3); // initial 404 + its retry + the other assessment + }); + + test("does not retry a non-404 failure", async () => { + let calls = 0; + const client = { + postResult: async () => { + calls++; + return { ok: false, status: 403, error: "forbidden" }; + }, + } as unknown as MlflowClient; + + const outcome = await reportToMlflow(client, [ + { + id: "x", + traceId: "tr-1", + assertions: [{ label: "reply", severity: "gate", pass: true }], + passed: true, + }, + ]); + expect(outcome.written).toBe(0); + expect(outcome.failures).toHaveLength(2); // both assessments failed, no retry + expect(calls).toBe(2); // one call each, no retries + }); + + test("routes a UC V4 trace id to the V4 endpoint with a bare assessment body", async () => { + const calls: Array<{ path: string; body: unknown }> = []; + const client = { + postResult: async (path: string, body: unknown) => { + calls.push({ path, body }); + return { ok: true }; + }, + } as unknown as MlflowClient; + + await reportToMlflow(client, [ + { + id: "query/sum", + traceId: "trace:/main.mario.2132184224222661/68b727fb0e5493f9", + assertions: [{ label: "reply", severity: "gate", pass: true }], + passed: true, + }, + ]); + + expect(calls).toHaveLength(2); // assertion + overall + for (const c of calls) { + // location + bare hex as separate path segments; no `trace:/` prefix. + expect(c.path).toBe( + "/api/4.0/mlflow/traces/main.mario.2132184224222661/68b727fb0e5493f9/assessments", + ); + // V4 body is the bare assessment, NOT wrapped in { assessment }. + expect(c.body).toHaveProperty("trace_id"); + expect(c.body).not.toHaveProperty("assessment"); + } + }); + + test("appends sql_warehouse_id to the V4 endpoint when provided", async () => { + const calls: string[] = []; + const client = { + postResult: async (path: string) => { + calls.push(path); + return { ok: true }; + }, + } as unknown as MlflowClient; + + await reportToMlflow( + client, + [ + { + id: "query/sum", + traceId: "trace:/main.mario.2132184224222661/68b727fb0e5493f9", + assertions: [{ label: "reply", severity: "gate", pass: true }], + passed: true, + }, + ], + "abc123warehouse", + ); + + expect(calls).toHaveLength(2); + for (const path of calls) { + expect(path).toBe( + "/api/4.0/mlflow/traces/main.mario.2132184224222661/68b727fb0e5493f9/assessments?sql_warehouse_id=abc123warehouse", + ); + } + }); + + test("routes a classic V3 trace id to the V3 endpoint with a wrapped body", async () => { + const calls: Array<{ path: string; body: unknown }> = []; + const client = { + postResult: async (path: string, body: unknown) => { + calls.push({ path, body }); + return { ok: true }; + }, + } as unknown as MlflowClient; + + await reportToMlflow(client, [ + { + id: "x", + traceId: "tr-abc", + assertions: [{ label: "reply", severity: "gate", pass: true }], + passed: true, + }, + ]); + + expect(calls).toHaveLength(2); + for (const c of calls) { + expect(c.path).toBe("/api/3.0/mlflow/traces/tr-abc/assessments"); + expect(c.body).toHaveProperty("assessment"); // V3 wraps in { assessment } + } + }); +}); diff --git a/packages/appkit/src/evals/tests/mlflow-run.test.ts b/packages/appkit/src/evals/tests/mlflow-run.test.ts new file mode 100644 index 000000000..872e0b11c --- /dev/null +++ b/packages/appkit/src/evals/tests/mlflow-run.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; + +import { aggregateMetrics } from "../mlflow-run"; +import type { EvalResult } from "../types"; + +describe("aggregateMetrics", () => { + test("counts total/scored/passed and computes pass_rate (skips excluded)", () => { + const results: EvalResult[] = [ + { id: "a", assertions: [], passed: true }, + { id: "b", assertions: [], passed: false }, + { id: "c", assertions: [], passed: true, skipped: { reason: "x" } }, + ]; + const metrics = aggregateMetrics(results, 1000); + const byKey = Object.fromEntries(metrics.map((m) => [m.key, m.value])); + expect(byKey["eval/total"]).toBe(3); + expect(byKey["eval/scored"]).toBe(2); + expect(byKey["eval/passed"]).toBe(1); + expect(byKey["eval/pass_rate"]).toBe(0.5); + expect(metrics.every((m) => m.timestamp === 1000 && m.step === 0)).toBe( + true, + ); + }); + + test("pass_rate is 0 when nothing is scored", () => { + const metrics = aggregateMetrics( + [{ id: "a", assertions: [], passed: true, skipped: {} }], + 1, + ); + expect(metrics.find((m) => m.key === "eval/pass_rate")?.value).toBe(0); + }); +}); diff --git a/packages/appkit/src/evals/tests/pool.test.ts b/packages/appkit/src/evals/tests/pool.test.ts new file mode 100644 index 000000000..1b1bee2df --- /dev/null +++ b/packages/appkit/src/evals/tests/pool.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; + +import { mapPool } from "../pool"; + +describe("mapPool", () => { + test("preserves input order even when later items finish first", async () => { + // Earlier items sleep longer, so they resolve after later ones. + const out = await mapPool([30, 20, 10, 0], 2, async (ms, i) => { + await new Promise((r) => setTimeout(r, ms)); + return i; + }); + expect(out).toEqual([0, 1, 2, 3]); + }); + + test("never exceeds the concurrency limit", async () => { + let inFlight = 0; + let maxInFlight = 0; + await mapPool( + Array.from({ length: 10 }, (_, i) => i), + 3, + async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + return null; + }, + ); + expect(maxInFlight).toBe(3); + }); + + test("processes every item exactly once", async () => { + const seen: number[] = []; + const out = await mapPool([1, 2, 3, 4, 5], 2, async (n) => { + seen.push(n); + return n * 2; + }); + expect(out).toEqual([2, 4, 6, 8, 10]); + expect(seen.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]); + }); + + test("clamps concurrency to [1, length] and handles empty input", async () => { + expect(await mapPool([1, 2], 99, async (n) => n)).toEqual([1, 2]); + expect(await mapPool([], 4, async (n) => n)).toEqual([]); + }); + + test("coerces a non-finite concurrency (NaN) to 1 instead of skipping every item", async () => { + // A bad CLI `--concurrency abc` reaches here as NaN. Without coercion, + // zero workers spawn and every item is silently skipped (undefined holes). + const seen: number[] = []; + const out = await mapPool([1, 2, 3], Number.NaN, async (n) => { + seen.push(n); + return n * 10; + }); + expect(out).toEqual([10, 20, 30]); + expect(seen.sort((a, b) => a - b)).toEqual([1, 2, 3]); + }); +}); diff --git a/packages/appkit/src/evals/tests/report.test.ts b/packages/appkit/src/evals/tests/report.test.ts new file mode 100644 index 000000000..49b80fe89 --- /dev/null +++ b/packages/appkit/src/evals/tests/report.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "vitest"; + +import { formatEvalResults, summarize } from "../report"; +import type { EvalResult } from "../types"; + +const results: EvalResult[] = [ + { + id: "a/pass", + assertions: [{ label: "succeeded", severity: "gate", pass: true }], + passed: true, + }, + { + id: "a/fail", + assertions: [ + { + label: "calledTool(x)", + severity: "gate", + pass: false, + detail: "not called", + }, + ], + passed: false, + }, + { + id: "a/skip", + assertions: [], + passed: true, + skipped: { reason: "no data" }, + }, +]; + +describe("eval reporting", () => { + test("summarize counts pass/fail/skip and allPassed", () => { + expect(summarize(results)).toEqual({ + total: 3, + passed: 1, + failed: 1, + skipped: 1, + allPassed: false, + }); + }); + + test("summarize allPassed is true when nothing failed", () => { + expect(summarize([results[0], results[2]]).allPassed).toBe(true); + }); + + test("formatEvalResults shows status, failing assertions, and a summary line", () => { + const out = formatEvalResults(results); + expect(out).toContain("✓ a/pass"); + expect(out).toContain("✗ a/fail"); + expect(out).toContain("[gate] calledTool(x) — not called"); + expect(out).toContain("a/skip (skipped: no data)"); + expect(out).toContain("FAIL — 1 passed, 1 failed, 1 skipped (3 total)"); + }); +}); diff --git a/packages/appkit/src/evals/tests/resolve-default.test.ts b/packages/appkit/src/evals/tests/resolve-default.test.ts new file mode 100644 index 000000000..2dcc8a449 --- /dev/null +++ b/packages/appkit/src/evals/tests/resolve-default.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; + +import { resolveEvalDefault } from "../run-evals"; + +const def = { description: "x", test: async () => {} }; + +describe("resolveEvalDefault (module interop)", () => { + test("pure ESM: mod.default", () => { + expect(resolveEvalDefault({ default: def })).toBe(def); + }); + + test("CJS __esModule double-wrap: mod.default.default", () => { + expect( + resolveEvalDefault({ default: { __esModule: true, default: def } }), + ).toBe(def); + }); + + test("direct: mod is the def", () => { + expect(resolveEvalDefault(def)).toBe(def); + }); + + test("no default export → undefined", () => { + expect(resolveEvalDefault({ default: { notTest: 1 } })).toBeUndefined(); + expect(resolveEvalDefault(null)).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/evals/tests/run-eval.test.ts b/packages/appkit/src/evals/tests/run-eval.test.ts new file mode 100644 index 000000000..828daea97 --- /dev/null +++ b/packages/appkit/src/evals/tests/run-eval.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "vitest"; + +import { defineEval } from "../define-eval"; +import { includes } from "../matchers"; +import { runEval } from "../run-eval"; +import type { DriveResult, EvalDriver } from "../types"; + +/** Driver that returns a fixed result for every send. */ +function fakeDriver(result: Partial): EvalDriver { + return { + send: async () => ({ + reply: "", + toolCalls: [], + succeeded: true, + ...result, + }), + }; +} + +describe("runEval", () => { + test("passes when all gate assertions pass", async () => { + const def = defineEval({ + async test(t) { + await t.send("weather?"); + t.succeeded(); + t.calledTool("get_weather"); + t.check(t.reply, includes("Sunny")); + }, + }); + const result = await runEval(def, { + id: "weather", + driver: fakeDriver({ + reply: "It is Sunny", + toolCalls: ["get_weather"], + succeeded: true, + }), + }); + expect(result.passed).toBe(true); + expect(result.assertions).toHaveLength(3); + expect(result.assertions.every((a) => a.pass)).toBe(true); + }); + + test("fails when a gate assertion fails", async () => { + const def = defineEval({ + async test(t) { + await t.send("weather?"); + t.calledTool("get_weather"); + }, + }); + const result = await runEval(def, { + id: "no-tool", + driver: fakeDriver({ reply: "I don't know", toolCalls: [] }), + }); + expect(result.passed).toBe(false); + expect(result.assertions[0].pass).toBe(false); + }); + + test("soft failures don't fail the eval unless strict", async () => { + const def = defineEval({ + async test(t) { + await t.send("hi"); + t.calledTool("get_weather").soft(); + }, + }); + const lenient = await runEval(def, { + id: "soft", + driver: fakeDriver({ toolCalls: [] }), + }); + expect(lenient.passed).toBe(true); + + const strict = await runEval(def, { + id: "soft", + driver: fakeDriver({ toolCalls: [] }), + strict: true, + }); + expect(strict.passed).toBe(false); + }); + + test("succeeded() reflects the driver's success flag", async () => { + const def = defineEval({ + async test(t) { + await t.send("hi"); + t.succeeded(); + }, + }); + const result = await runEval(def, { + id: "failed-turn", + driver: fakeDriver({ succeeded: false }), + }); + expect(result.passed).toBe(false); + }); + + test("t.skip marks the eval skipped and passing", async () => { + const def = defineEval({ + test(t) { + t.skip("no fixture data"); + }, + }); + const result = await runEval(def, { + id: "skipped", + driver: fakeDriver({}), + }); + expect(result.skipped?.reason).toBe("no fixture data"); + expect(result.passed).toBe(true); + expect(result.assertions).toHaveLength(0); + }); + + test("a thrown error becomes a non-passing result, not an exception", async () => { + const def = defineEval({ + async test() { + throw new Error("boom"); + }, + }); + const result = await runEval(def, { id: "boom", driver: fakeDriver({}) }); + expect(result.passed).toBe(false); + expect(result.error).toBe("boom"); + }); +}); diff --git a/packages/appkit/src/evals/types.ts b/packages/appkit/src/evals/types.ts new file mode 100644 index 000000000..b4e7df9b0 --- /dev/null +++ b/packages/appkit/src/evals/types.ts @@ -0,0 +1,135 @@ +/** + * Agent evaluation primitives — an eve-style authoring API that runs against + * AppKit agents and reports to Databricks MLflow. + * + * Evals live in `server/agents//evals/*.eval.ts`, each default-exporting a + * {@link EvalDefinition} via {@link defineEval}. A runner drives the agent + * (today over HTTP against a running app), and the `test` function asserts on + * the reply and tool usage with deterministic matchers (and, later, LLM judges). + */ + +/** Result of a deterministic matcher run against a value. */ +export interface MatchResult { + pass: boolean; + /** Optional 0..1 score for scored matchers (similarity, judges). */ + score?: number; + /** Human-readable explanation, shown on failure. */ + detail?: string; +} + +/** A deterministic matcher: inspects a string value and returns a result. */ +export type Matcher = (value: string) => MatchResult; + +/** Whether an assertion fails the eval (`gate`) or is tracked only (`soft`). */ +export type Severity = "gate" | "soft"; + +/** A single recorded assertion outcome. */ +export interface AssertionResult { + label: string; + severity: Severity; + pass: boolean; + score?: number; + detail?: string; +} + +/** + * Chainable handle returned by every assertion to control its severity. + * Mirrors eve: assertions are gates by default; `.soft()` demotes to a tracked + * metric; `.atLeast(n)` is a soft, score-thresholded assertion. + */ +export interface AssertionHandle { + /** Promote to a hard gate — failure fails the eval (non-zero exit). */ + gate(): AssertionHandle; + /** Demote to a tracked metric — doesn't fail unless running with `strict`. */ + soft(): AssertionHandle; + /** Soft assertion that passes only when the score is at least `threshold`. */ + atLeast(threshold: number): AssertionHandle; +} + +/** What a driver returns for a single `t.send`. */ +export interface DriveResult { + /** The final assistant message text. */ + reply: string; + /** Names of tools the agent called during the turn. */ + toolCalls: string[]; + /** Whether the turn completed without an agent/stream error. */ + succeeded: boolean; + /** Thread/session id, when the driver exposes one. */ + sessionId?: string; + /** MLflow trace id for the turn, when tracing is enabled on the app. */ + traceId?: string; +} + +/** + * Abstraction over how the agent is driven. The HTTP driver posts to a running + * app's agents endpoint; future drivers (in-process) implement the same shape. + */ +export interface EvalDriver { + send(message: string): Promise; +} + +/** The `t` context passed to an eval's `test` function. */ +export interface TestContext { + /** Send a user message to the agent and capture its response. */ + send(message: string): Promise; + /** The last assistant reply. */ + readonly reply: string; + /** Tools called during the last turn. */ + readonly toolCalls: string[]; + /** The current session/thread id, if any. */ + readonly sessionId: string | undefined; + /** Assert the last turn completed successfully (gate by default). */ + succeeded(): AssertionHandle; + /** Assert a tool was called during the run (gate by default). */ + calledTool(name: string): AssertionHandle; + /** Assert a value against a matcher, e.g. `t.check(t.reply, includes("Sunny"))`. */ + check(value: string, matcher: Matcher): AssertionHandle; + /** + * LLM-as-judge scoring of the last reply (via autoevals → a Databricks judge + * model). Each returns a scored, soft-by-default assertion; chain `.atLeast(n)` + * to set the pass threshold or `.gate()` to make it a hard gate. Requires the + * judge to be configured (`--judge-model`). + */ + judge: { + /** Score factuality of the reply against an expected reference. */ + factuality(expected: string): Promise; + /** Score whether the reply answers the question, per optional `criteria`. */ + closedQA(criteria: string): Promise; + /** A custom prompt-template judge (the TS analog of MLflow's `@scorer`). */ + custom(spec: CustomJudgeSpec): Promise; + }; + /** Skip this eval with an optional reason. */ + skip(reason?: string): never; +} + +/** A custom LLM-judge definition: a prompt template and choice→score mapping. */ +export interface CustomJudgeSpec { + name: string; + promptTemplate: string; + choiceScores: Record; +} + +/** A single eval, default-exported from a `*.eval.ts` file. */ +export interface EvalDefinition { + /** Short human description, shown in reports. */ + description?: string; + /** Target agent id. Defaults to the eval's parent `server/agents/` dir. */ + agent?: string; + /** The eval body: drive the agent and assert on its behavior. */ + test(t: TestContext): Promise | void; +} + +/** The outcome of running one eval. */ +export interface EvalResult { + id: string; + description?: string; + /** Set when the eval called `t.skip`. */ + skipped?: { reason?: string }; + assertions: AssertionResult[]; + /** True when all gates passed (and, under strict, all soft assertions too). */ + passed: boolean; + /** Set when the eval threw before completing. */ + error?: string; + /** MLflow trace id of the eval's last turn, for attaching assessments. */ + traceId?: string; +} diff --git a/packages/shared/src/cli/commands/agent/eval.ts b/packages/shared/src/cli/commands/agent/eval.ts new file mode 100644 index 000000000..2686845ea --- /dev/null +++ b/packages/shared/src/cli/commands/agent/eval.ts @@ -0,0 +1,288 @@ +import { Command } from "commander"; + +interface EvalRunSummary { + results: unknown[]; + mlflow?: { + runId: string; + report: { + written: number; + skipped: number; + failures: Array<{ traceId: string; status?: number; error?: string }>; + }; + finish: { finished: boolean; metricsError?: string; finishError?: string }; + }; +} + +type EvalProgress = + | { type: "discovered"; total: number } + | { type: "run-created"; runId: string } + | { type: "start"; id: string; index: number; total: number } + | { type: "result"; result: unknown; index: number; total: number }; + +/** Subset of `@databricks/appkit/beta`'s eval runner used by this command. */ +interface EvalRunner { + runEvalsInDir(opts: { + rootDir?: string; + baseUrl: string; + filter?: string; + strict?: boolean; + headers?: Record; + concurrency?: number; + mlflow?: { + host: string; + token: string; + experimentId: string; + sqlWarehouseId?: string; + }; + judge?: { host: string; token: string; model: string }; + onEvent?: (event: EvalProgress) => void; + }): Promise; + resolveDatabricksAuth(opts: { + profile?: string; + host?: string; + token?: string; + }): Promise<{ host: string; token: string } | undefined>; + formatEvalHeadline(result: unknown): string; + formatEvalDetail(result: unknown): string[]; + formatSummaryLine(results: unknown[]): string; + summarize(results: unknown[]): { allPassed: boolean }; +} + +/** + * Loaded at runtime from the consuming project so this command (which ships in + * `@databricks/shared`) doesn't take a build-time dependency on appkit. The + * specifier is a variable so the type checker treats it as `any`. + */ +async function loadRunner(): Promise { + const spec = "@databricks/appkit/beta"; + try { + return (await import(spec)) as unknown as EvalRunner; + } catch (err) { + throw new Error( + "Could not load @databricks/appkit. Run `appkit agent eval` from a " + + "project with @databricks/appkit installed. " + + `Cause: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +function parseHeaders(values: string[]): Record { + const headers: Record = {}; + for (const v of values) { + const i = v.indexOf(":"); + if (i === -1) continue; + headers[v.slice(0, i).trim()] = v.slice(i + 1).trim(); + } + return headers; +} + +interface EvalOptions { + url: string; + strict?: boolean; + root?: string; + header?: string[]; + profile?: string; + databricksHost?: string; + databricksToken?: string; + experiment?: string; + judgeModel?: string; + concurrency?: number; + warehouseId?: string; +} + +/** Resolved Databricks host + bearer (either field may be absent). */ +type Auth = { host?: string; token?: string }; + +/** + * Native MLflow "Evaluation run" config — only when creds + an experiment are + * all present (traces live in the app; the run + scores are driven from here). + */ +function resolveMlflow(opts: EvalOptions, auth: Auth) { + const experimentId = opts.experiment ?? process.env.MLFLOW_EXPERIMENT_ID; + if (!(auth.host && auth.token && experimentId)) return undefined; + // UC-backed experiments need a SQL warehouse to write assessments to their + // V4 traces. Mirror mlflow's env var, and accept the common DATABRICKS one. + const sqlWarehouseId = + opts.warehouseId ?? + process.env.MLFLOW_TRACING_SQL_WAREHOUSE_ID ?? + process.env.DATABRICKS_WAREHOUSE_ID; + return { + host: auth.host, + token: auth.token, + experimentId, + ...(sqlWarehouseId ? { sqlWarehouseId } : {}), + }; +} + +/** LLM-as-judge config — reuses the Databricks creds + a judge serving endpoint. */ +function resolveJudge(opts: EvalOptions, auth: Auth) { + const model = opts.judgeModel ?? process.env.APPKIT_JUDGE_MODEL; + return model && auth.host && auth.token + ? { host: auth.host, token: auth.token, model } + : undefined; +} + +/** Progress reporter: stream each eval as it runs instead of going silent. */ +function makeProgressReporter( + runner: EvalRunner, + url: string, +): (event: EvalProgress) => void { + return (event) => { + switch (event.type) { + case "discovered": + console.log( + `Running ${event.total} eval${event.total === 1 ? "" : "s"} against ${url}\n`, + ); + break; + case "run-created": + console.log(`MLflow evaluation run: ${event.runId}\n`); + break; + case "result": { + // One full line per completion — evals run concurrently, so a split + // "start … glyph" prefix would interleave into garbage. + console.log( + `[${event.index + 1}/${event.total}] ${runner.formatEvalHeadline(event.result)}`, + ); + for (const line of runner.formatEvalDetail(event.result)) { + console.log(line); + } + break; + } + } + }; +} + +function formatFailureLine(f: { + traceId: string; + status?: number; + error?: string; +}): string { + return ` ✗ trace ${f.traceId}: ${f.status ?? ""} ${f.error ?? ""}`.trim(); +} + +/** Print the MLflow assessment/finish outcome after a run that created one. */ +function printMlflowOutcome( + mlflow: NonNullable, +): void { + const { report, finish } = mlflow; + console.log( + `MLflow: ${report.written} assessment(s) written` + + (report.skipped ? `, ${report.skipped} skipped` : "") + + (report.failures.length ? `, ${report.failures.length} failed` : ""), + ); + for (const f of report.failures) { + console.error(formatFailureLine(f)); + } + if (finish.metricsError) { + console.error(` ⚠ metrics not logged: ${finish.metricsError}`); + } + if (!finish.finished) { + console.error( + ` ✗ run left RUNNING — failed to finish: ${finish.finishError ?? "unknown"}`, + ); + } +} + +async function runAgentEval( + filter: string | undefined, + opts: EvalOptions, +): Promise { + const runner = await loadRunner(); + + // Resolve Databricks host + bearer the AppKit-native way: an explicit + // host/token (or DATABRICKS_* env) wins; otherwise the SDK mints an OAuth + // token from the CLI profile — so no hand-set PAT is required. + const auth: Auth = + (await runner.resolveDatabricksAuth({ + profile: opts.profile ?? process.env.DATABRICKS_CONFIG_PROFILE, + host: opts.databricksHost ?? process.env.DATABRICKS_HOST, + token: opts.databricksToken ?? process.env.DATABRICKS_TOKEN, + })) ?? {}; + + let summary: EvalRunSummary; + try { + summary = await runner.runEvalsInDir({ + rootDir: opts.root, + baseUrl: opts.url, + filter, + strict: opts.strict, + headers: opts.header ? parseHeaders(opts.header) : undefined, + concurrency: opts.concurrency, + mlflow: resolveMlflow(opts, auth), + judge: resolveJudge(opts, auth), + onEvent: makeProgressReporter(runner, opts.url), + }); + } catch (err) { + // Setup failures (e.g. a bad --experiment for the MLflow run) reject before + // any eval runs; surface a clean message + non-zero exit rather than an + // unhandled promise rejection with a raw stack. + console.error( + `\nEval run failed: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exitCode = 1; + return; + } + console.log(`\n${runner.formatSummaryLine(summary.results)}`); + + if (summary.mlflow) { + printMlflowOutcome(summary.mlflow); + } else { + console.log( + "\nMLflow evaluation run skipped — pass --experiment (or set" + + " MLFLOW_EXPERIMENT_ID) plus --profile/--databricks-host to create one.", + ); + } + + if (!runner.summarize(summary.results).allPassed) { + process.exitCode = 1; + } +} + +export const agentEvalCommand = new Command("eval") + .description( + "Run agent evals (server/agents//evals/*.eval.ts) against a running app", + ) + .argument( + "[filter]", + "Only run evals whose / contains this substring (or an exact agent id)", + ) + .option("--url ", "Base URL of the running app", "http://localhost:3000") + .option("--strict", "Fail on soft-assertion misses too", false) + .option( + "--concurrency ", + "Max evals to run concurrently (default 4; keep at or below the app's max concurrent streams per user)", + (v) => Number.parseInt(v, 10), + ) + .option( + "--root ", + "Project root containing server/agents/ (default: cwd)", + ) + .option( + "--header ", + "Extra request header as 'Key: value' (repeatable)", + ) + .option( + "--profile ", + "Databricks CLI profile to authenticate with via OAuth (default: DATABRICKS_CONFIG_PROFILE)", + ) + .option( + "--databricks-host ", + "Databricks host for writing MLflow assessments (default: DATABRICKS_HOST)", + ) + .option( + "--databricks-token ", + "Databricks token for writing MLflow assessments (default: DATABRICKS_TOKEN)", + ) + .option( + "--experiment ", + "MLflow experiment id for the evaluation run (default: MLFLOW_EXPERIMENT_ID)", + ) + .option( + "--warehouse-id ", + "SQL warehouse id for writing assessments to UC-backed experiments (default: MLFLOW_TRACING_SQL_WAREHOUSE_ID or DATABRICKS_WAREHOUSE_ID)", + ) + .option( + "--judge-model ", + "Databricks serving endpoint to use as the LLM judge for t.judge.* (default: APPKIT_JUDGE_MODEL)", + ) + .action(runAgentEval); diff --git a/packages/shared/src/cli/commands/agent/index.ts b/packages/shared/src/cli/commands/agent/index.ts new file mode 100644 index 000000000..9ef8b6cf5 --- /dev/null +++ b/packages/shared/src/cli/commands/agent/index.ts @@ -0,0 +1,20 @@ +import { Command } from "commander"; + +import { agentEvalCommand } from "./eval"; + +/** + * Parent command for agent development operations. + * Subcommands: + * - eval: Run agent evals against a running app + */ +export const agentCommand = new Command("agent") + .description("Agent development commands") + .addCommand(agentEvalCommand) + .addHelpText( + "after", + ` +Examples: + $ appkit agent eval + $ appkit agent eval support --strict + $ appkit agent eval --url https://my-app.databricksapps.com`, + ); diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index 8c4649419..a228b1f90 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { Command } from "commander"; +import { agentCommand } from "./commands/agent/index.js"; import { codemodCommand } from "./commands/codemod/index.js"; import { docsCommand } from "./commands/docs.js"; import { doctorCommand } from "./commands/doctor/index.js"; @@ -38,5 +39,6 @@ cmd.addCommand(doctorCommand); // is still in development (registry + add work end-to-end but aren't announced). cmd.addCommand(registryCommand, { hidden: true }); cmd.addCommand(addCommand, { hidden: true }); +cmd.addCommand(agentCommand); await cmd.parseAsync(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ee76abd4..8c6660f4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,9 @@ importers: apache-arrow: specifier: 21.1.0 version: 21.1.0 + autoevals: + specifier: 0.3.0 + version: 0.3.0(ws@8.21.0(bufferutil@4.0.9))(zod@4.3.6) dotenv: specifier: 16.6.1 version: 16.6.1 @@ -5894,6 +5897,12 @@ packages: autocomplete.js@0.37.1: resolution: {integrity: sha512-PgSe9fHYhZEsm/9jggbjtVsGXJkPLvd+9mC7gZJ662vVL5CRWEtm/mIrrzCx0MrNxHVwxD5d00UOn6NsmL2LUQ==} + autoevals@0.3.0: + resolution: {integrity: sha512-4CEzBVhjVBHvk46s+DBcgmEfOdM+zXEoCkvvDqYO2IWdVpZKUJANfX8BvfE5vfcGy24on9zW21Zf7V9cUJdbfg==} + engines: {npm: please-use-pnpm, pnpm: '>=10.27.0', yarn: please-use-pnpm} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + autoprefixer@10.4.21: resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -6040,6 +6049,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + binary-search@1.3.6: + resolution: {integrity: sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -6241,6 +6253,9 @@ packages: resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} engines: {node: '>=20.18.1'} + cheminfo-types@1.15.0: + resolution: {integrity: sha512-shv45WN2u0yN9EHH1bisNrv+fy4Cw+eLM5lOoriP67mePrwbHZ1kJqg90C8GEU7K1A8gJsicEoVZHcuBbuul/w==} + chevrotain-allstar@0.3.1: resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} peerDependencies: @@ -6437,6 +6452,15 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} + compute-cosine-similarity@1.1.0: + resolution: {integrity: sha512-FXhNx0ILLjGi9Z9+lglLzM12+0uoTnYkHm7GiadXDAr0HGVLm25OivUS1B/LPkbzzvlcXz/1EvWg9ZYyJSdhTw==} + + compute-dot@1.1.0: + resolution: {integrity: sha512-L5Ocet4DdMrXboss13K59OK23GXjiSia7+7Ukc7q4Bl+RVpIXK2W9IHMbWDZkh+JUEvJAwOKRaJDiFUa1LTnJg==} + + compute-l2norm@1.1.0: + resolution: {integrity: sha512-6EHh1Elj90eU28SXi+h2PLnTQvZmkkHWySpoFz+WOlVNLz3DQoC4ISUHSV9n5jMxPHtKGJ01F4uu2PsXBB8sSg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -7618,6 +7642,9 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fft.js@4.0.4: + resolution: {integrity: sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -8377,6 +8404,9 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-any-array@3.0.0: + resolution: {integrity: sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -8620,6 +8650,10 @@ packages: jose@6.2.10: resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8849,6 +8883,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + linear-sum-assignment@1.0.9: + resolution: {integrity: sha512-1T2Ek3sxpt2mBHeBFMRJEikiIK/yIOwf+mrxv/DkAU/5ddnCMndZL//hFH7QuHa1tbaQADzsf9t7rkGZKqoFfQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -9392,6 +9429,24 @@ packages: engines: {node: '>=10'} hasBin: true + ml-array-max@2.0.0: + resolution: {integrity: sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A==} + + ml-array-min@2.0.0: + resolution: {integrity: sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg==} + + ml-array-rescale@2.0.0: + resolution: {integrity: sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg==} + + ml-matrix@6.15.0: + resolution: {integrity: sha512-wFa1v6KP8bKp+fj0nYmRs1Pb5K4zRkXGKsOvLinvILENFIADncm4XlOI+S1M7yuACMGfI6cfk0IifDgd4j5xmw==} + + ml-spectra-processing@14.34.0: + resolution: {integrity: sha512-sNM3nOg7s4tyinRnU5FyYZFV//cPHMvjwfxQvFh3GyrvWNuZmPIZTPS+kiT/SPXMymxA4+qx8oFSZ9K03clGyQ==} + + ml-xsadd@3.0.1: + resolution: {integrity: sha512-Fz2q6dwgzGM8wYKGArTUTZDGa4lQFA2Vi6orjGeTVRy22ZnQFKlJuwS9n8NRviqz1KHAHAzdKJwbnYhdo38uYg==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -9422,6 +9477,10 @@ packages: resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} hasBin: true + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -9658,6 +9717,26 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openai@6.49.0: + resolution: {integrity: sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -12043,6 +12122,12 @@ packages: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} + validate.io-array@1.0.6: + resolution: {integrity: sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==} + + validate.io-function@1.0.2: + resolution: {integrity: sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==} + validator@13.15.26: resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} engines: {node: '>= 0.10'} @@ -12464,6 +12549,11 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zod-to-json-schema@3.25.0: + resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} + peerDependencies: + zod: ^3.25 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -18880,6 +18970,23 @@ snapshots: dependencies: immediate: 3.3.0 + autoevals@0.3.0(ws@8.21.0(bufferutil@4.0.9))(zod@4.3.6): + dependencies: + ajv: 8.18.0 + compute-cosine-similarity: 1.1.0 + js-levenshtein: 1.1.6 + js-yaml: 4.3.1 + linear-sum-assignment: 1.0.9 + mustache: 4.2.0 + openai: 6.49.0(ws@8.21.0(bufferutil@4.0.9))(zod@4.3.6) + zod: 4.3.6 + zod-to-json-schema: 3.25.0(zod@4.3.6) + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - ws + autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -19017,6 +19124,8 @@ snapshots: binary-extensions@2.3.0: {} + binary-search@1.3.6: {} + birpc@4.0.0: {} bl@4.1.0: @@ -19312,6 +19421,8 @@ snapshots: undici: 7.24.5 whatwg-mimetype: 4.0.0 + cheminfo-types@1.15.0: {} + chevrotain-allstar@0.3.1(chevrotain@11.0.3): dependencies: chevrotain: 11.0.3 @@ -19504,6 +19615,23 @@ snapshots: transitivePeerDependencies: - supports-color + compute-cosine-similarity@1.1.0: + dependencies: + compute-dot: 1.1.0 + compute-l2norm: 1.1.0 + validate.io-array: 1.0.6 + validate.io-function: 1.0.2 + + compute-dot@1.1.0: + dependencies: + validate.io-array: 1.0.6 + validate.io-function: 1.0.2 + + compute-l2norm@1.1.0: + dependencies: + validate.io-array: 1.0.6 + validate.io-function: 1.0.2 + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -20707,6 +20835,8 @@ snapshots: fflate@0.8.3: {} + fft.js@4.0.4: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -21687,6 +21817,8 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-any-array@3.0.0: {} + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -21897,6 +22029,8 @@ snapshots: jose@6.2.10: optional: true + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -22115,6 +22249,12 @@ snapshots: lilconfig@3.1.3: {} + linear-sum-assignment@1.0.9: + dependencies: + cheminfo-types: 1.15.0 + ml-matrix: 6.15.0 + ml-spectra-processing: 14.34.0 + lines-and-columns@1.2.4: {} linkify-it@5.0.0: @@ -22953,6 +23093,36 @@ snapshots: mkdirp@3.0.1: {} + ml-array-max@2.0.0: + dependencies: + is-any-array: 3.0.0 + + ml-array-min@2.0.0: + dependencies: + is-any-array: 3.0.0 + + ml-array-rescale@2.0.0: + dependencies: + is-any-array: 3.0.0 + ml-array-max: 2.0.0 + ml-array-min: 2.0.0 + + ml-matrix@6.15.0: + dependencies: + is-any-array: 3.0.0 + ml-array-rescale: 2.0.0 + + ml-spectra-processing@14.34.0: + dependencies: + binary-search: 1.3.6 + cheminfo-types: 1.15.0 + fft.js: 4.0.4 + is-any-array: 3.0.0 + ml-matrix: 6.15.0 + ml-xsadd: 3.0.1 + + ml-xsadd@3.0.1: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -22981,6 +23151,8 @@ snapshots: dns-packet: 5.6.1 thunky: 1.1.0 + mustache@4.2.0: {} + mute-stream@2.0.0: {} nanoid@3.3.11: {} @@ -23208,6 +23380,11 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@6.49.0(ws@8.21.0(bufferutil@4.0.9))(zod@4.3.6): + optionalDependencies: + ws: 8.21.0(bufferutil@4.0.9) + zod: 4.3.6 + opener@1.5.2: {} openid-client@6.8.7: @@ -25872,6 +26049,10 @@ snapshots: validate-npm-package-name@7.0.2: {} + validate.io-array@1.0.6: {} + + validate.io-function@1.0.2: {} + validator@13.15.26: {} value-equal@1.0.1: {} @@ -26452,6 +26633,10 @@ snapshots: yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.0(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.1.13): dependencies: zod: 4.1.13