') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(eval): ab-test target-based run (create) by jariy17 · Pull Request #2135 · aws/agentcore-cli · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 115 additions & 47 deletions src/core/eval.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,7 @@ import {
type EvaluationReferenceInput,
type EvaluationResultContent,
type EvaluationTarget,
type CreateABTestRequest,
type CreateABTestResponse,
type GetABTestResponse,
type ListABTestsResponse,
Expand DownExpand Up@@ -123,6 +124,7 @@ import type {
CoreEvalClient,
CreateConfigurationBundleInput,
CreateConfigBundleABTestInput,
CreateTargetBasedABTestInput,
CreateDatasetInput,
CreateOnlineEvalInput,
CreateOnlineInsightInput,
Expand DownExpand Up@@ -481,65 +483,38 @@ export class EvalClient implements CoreEvalClient {
.send(new DeleteABTestCommand({ abTestId: id }));
}

async createConfigBundleABTest(
input: CreateConfigBundleABTestInput,
private async createABTest(
name: string,
gateway: string,
callerRoleArn: string | undefined,
build: (context: {
gatewayArn: string;
accountId: string;
roleArn: string;
}) => CreateABTestRequest,
options: CoreOptions,
): Promise<CreateABTestResponse> {
const control = this.clients.control(toClientConfig(options));
const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: input.gateway }));
const gatewayArn = gateway.gatewayArn!;
const gatewayArn = (await control.send(new GetGatewayCommand({ gatewayIdentifier: gateway })))
.gatewayArn!;
const accountId = accountIdFromArn(gatewayArn);

const controlBundleArn = `arn:aws:bedrock-agentcore:${options.region}:${accountId}:configuration-bundle/${input.control.configBundle}`;
const treatmentBundleArn = `arn:aws:bedrock-agentcore:${options.region}:${accountId}:configuration-bundle/${input.treatment.configBundle}`;
const onlineEvaluationConfigArn = `arn:aws:bedrock-agentcore:${options.region}:${accountId}:online-evaluation-config/${input.onlineEval}`;

const treatmentWeight = input.treatmentWeight ?? 50;
const variants = [
{
name: "C",
weight: 100 - treatmentWeight,
variantConfiguration: {
configurationBundle: {
bundleArn: controlBundleArn,
bundleVersion: input.control.bundleVersion,
},
},
},
{
name: "T1",
weight: treatmentWeight,
variantConfiguration: {
configurationBundle: {
bundleArn: treatmentBundleArn,
bundleVersion: input.treatment.bundleVersion,
},
},
},
];

let roleArn = input.roleArn;
let roleArn = callerRoleArn;
let provisionedRoleArn: string | undefined;
if (!roleArn) {
const iam = this.clients.iam({ region: options.region });
const provisioned = await provisionAbTestRole(iam, input.name, gatewayArn, options.region);
const provisioned = await provisionAbTestRole(
this.clients.iam({ region: options.region }),
name,
gatewayArn,
options.region,
);
roleArn = provisioned.roleArn;
if (provisioned.created) provisionedRoleArn = provisioned.roleArn;
}

const command = new CreateABTestCommand({
name: input.name,
gatewayArn,
variants,
evaluationConfig: { onlineEvaluationConfigArn },
roleArn,
gatewayFilter: input.gatewayFilter,
enableOnCreate: input.enableOnCreate ?? true,
clientToken: randomUUID(),
});

const command = new CreateABTestCommand(build({ gatewayArn, accountId, roleArn }));
try {
return input.roleArn
return callerRoleArn
? await this.clients.data(toClientConfig(options)).send(command)
: await retryWhileRolePropagates(() =>
this.clients.data(toClientConfig(options)).send(command),
Expand All@@ -556,6 +531,99 @@ export class EvalClient implements CoreEvalClient {
}
}

async createConfigBundleABTest(
input: CreateConfigBundleABTestInput,
options: CoreOptions,
): Promise<CreateABTestResponse> {
const treatmentWeight = input.treatmentWeight ?? 50;
return this.createABTest(
input.name,
input.gateway,
input.roleArn,
({ gatewayArn, accountId, roleArn }) => {
const bundleArn = (id: string) =>
`arn:aws:bedrock-agentcore:${options.region}:${accountId}:configuration-bundle/${id}`;
return {
name: input.name,
gatewayArn,
variants: [
{
name: "C",
weight: 100 - treatmentWeight,
variantConfiguration: {
configurationBundle: {
bundleArn: bundleArn(input.control.configBundle),
bundleVersion: input.control.bundleVersion,
},
},
},
{
name: "T1",
weight: treatmentWeight,
variantConfiguration: {
configurationBundle: {
bundleArn: bundleArn(input.treatment.configBundle),
bundleVersion: input.treatment.bundleVersion,
},
},
},
],
evaluationConfig: {
onlineEvaluationConfigArn: `arn:aws:bedrock-agentcore:${options.region}:${accountId}:online-evaluation-config/${input.onlineEval}`,
},
roleArn,
gatewayFilter: input.gatewayFilter,
enableOnCreate: input.enableOnCreate ?? true,
clientToken: randomUUID(),
};
},
options,
);
}

async createTargetBasedABTest(
input: CreateTargetBasedABTestInput,
options: CoreOptions,
): Promise<CreateABTestResponse> {
const treatmentWeight = input.treatmentWeight ?? 50;
return this.createABTest(
input.name,
input.gateway,
input.roleArn,
({ gatewayArn, accountId, roleArn }) => {
const evalArn = (id: string) =>
`arn:aws:bedrock-agentcore:${options.region}:${accountId}:online-evaluation-config/${id}`;
return {
name: input.name,
gatewayArn,
variants: [
{
name: "C",
weight: 100 - treatmentWeight,
variantConfiguration: { target: { name: input.control.gatewayTarget } },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is target expecting a name or id here? src/handlers/eval/ab-test/target-based/run/index.tsx#L21 documents gateway-target as <id>; I'd expect input.control.gatewayTarget is then the target ID. But this field looks to expect target name?

},
{
name: "T1",
weight: treatmentWeight,
variantConfiguration: { target: { name: input.treatment.gatewayTarget } },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same question as above

},
],
evaluationConfig: {
perVariantOnlineEvaluationConfig: [
{ name: "C", onlineEvaluationConfigArn: evalArn(input.control.onlineEval) },
{ name: "T1", onlineEvaluationConfigArn: evalArn(input.treatment.onlineEval) },
],
},
roleArn,
gatewayFilter: input.gatewayFilter,
enableOnCreate: input.enableOnCreate ?? true,
clientToken: randomUUID(),
};
},
options,
);
}

async listBatchInsights(
nextToken: string | undefined,
maxResults: number | undefined,
Expand Down
86 changes: 86 additions & 0 deletions src/handlers/eval/ab-test/ab-test.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,9 +79,12 @@ describe("eval ab-test command hierarchy", () => {
"stop",
"delete",
"config-bundle",
"target-based",
]);
const cb = group?.children().find((c) => c.name() === "config-bundle");
expect(cb?.children().map((c) => c.name())).toEqual(["run"]);
const tb = group?.children().find((c) => c.name() === "target-based");
expect(tb?.children().map((c) => c.name())).toEqual(["run"]);
});
});

Expand DownExpand Up@@ -264,3 +267,86 @@ describe("eval ab-test config-bundle run validation", () => {
});
});
});

describe("eval ab-test target-based run validation", () => {
const TB_BASE = [
"eval",
"ab-test",
"target-based",
"run",
"--name",
"orders-v2-canary",
"--gateway",
"orders-gateway-abc123",
"--control",
'{"gateway-target":"orders-prod-target","online-eval":"prod-quality"}',
"--treatment",
'{"gateway-target":"orders-v2-target","online-eval":"v2-quality"}',
"--json",
];

test.each(["name", "gateway", "control", "treatment"] as const)(
"requires --%s",
async (missing) => {
const args = TB_BASE.filter(
(a, i) => a !== `--${missing}` && TB_BASE[i - 1] !== `--${missing}`,
);
await expect(run(args)).rejects.toThrow(new RegExp(`--${missing}`));
},
);

test("rejects a mis-shaped --control object", async () => {
const args = TB_BASE.map((a) =>
a === '{"gateway-target":"orders-prod-target","online-eval":"prod-quality"}'
? '{"wrong":"shape"}'
: a,
);
await expect(run(args)).rejects.toThrow(/--control must be/);
});

test("rejects identical control/treatment targets", async () => {
const same = '{"gateway-target":"t","online-eval":"e"}';
await expect(
run([
"eval",
"ab-test",
"target-based",
"run",
"--name",
"x",
"--gateway",
"g",
"--control",
same,
"--treatment",
same,
"--json",
]),
).rejects.toThrow(/different gateway targets/);
});

test("maps flags to a createTargetBasedABTest call", async () => {
const { core } = await run([...TB_BASE, "--treatment-weight", "20"], (c) =>
c.eval.setAbTestCreateResponse({
abTestId: "x",
abTestArn: ARN,
name: "x",
status: "CREATING",
executionStatus: "RUNNING",
createdAt: new Date("2026-08-26T10:00:00.000Z"),
}),
);
const call = core.eval.calls.find((c) => c.method === "createTargetBasedABTest");
expect(call).toBeDefined();
expect(call!.args[0]).toEqual({
name: "orders-v2-canary",
gateway: "orders-gateway-abc123",
control: { gatewayTarget: "orders-prod-target", onlineEval: "prod-quality" },
treatment: { gatewayTarget: "orders-v2-target", onlineEval: "v2-quality" },
treatmentWeight: 20,
gatewayFilter: undefined,
roleArn: undefined,
enableOnCreate: undefined,
});
});
});
4 changes: 3 additions & 1 deletion src/handlers/eval/ab-test/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { createResumeAbTestHandler } from "./resume";
import { createStopAbTestHandler } from "./stop";
import { createDeleteAbTestHandler } from "./delete";
import { createConfigBundleAbTestHandler } from "./config-bundle";
import { createTargetBasedAbTestHandler } from "./target-based";

export function createAbTestHandler(core: Core, io: AppIO): Router {
return new Router("ab-test", "inspect AgentCore A/B tests")
Expand All@@ -22,7 +23,8 @@ export function createAbTestHandler(core: Core, io: AppIO): Router {
.handler(createResumeAbTestHandler(core))
.handler(createStopAbTestHandler(core))
.handler(createDeleteAbTestHandler(core))
.handler(createConfigBundleAbTestHandler(core, io));
.handler(createConfigBundleAbTestHandler(core, io))
.handler(createTargetBasedAbTestHandler(core, io));
}

export { AbTestScreen } from "./screen.tsx";
10 changes: 10 additions & 0 deletions src/handlers/eval/ab-test/target-based/index.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { Router } from "../../../../router";
import type { AppIO } from "../../../../io";
import type { Core } from "../../../types";
import { createTargetBasedRunHandler } from "./run";

export function createTargetBasedAbTestHandler(core: Core, io: AppIO): Router {
return new Router("target-based", "target-based A/B tests").handler(
createTargetBasedRunHandler(core, io),
);
}
Loading
Loading