Skip to content
Closed
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
2 changes: 1 addition & 1 deletion src/assets/cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"typescript": "~5.9.3"
},
"dependencies": {
"@aws/agentcore-cdk": "^0.1.0-alpha.19",
"@aws/agentcore-cdk": "^0.1.0-alpha.29",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocker — snapshot out of date.

Bumping this to ^0.1.0-alpha.29 changes the vended CDK package.json, which is captured verbatim by src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap (line ~360 still pins ^0.1.0-alpha.19). The assets snapshot test will fail in CI.

The PR description's checklist says:

only the @aws/agentcore-cdk version pin in src/assets/cdk/package.json changed; no source under src/assets/ that affects assertion snapshots.

That's not quite right — the assets snapshot test iterates every file under src/assets/ and snapshots the raw contents, so any change to package.json (including a version pin) needs snapshot regeneration.

Please run npm run test:snapshots:update (or npm run test:update-snapshots, per your Testing section) and commit the updated .snap file.


Second blocker on this same line: @aws/agentcore-cdk@0.1.0-alpha.29 is not published to npm yet (latest published on registry.npmjs.org is alpha.28). Merging this PR as-is will break:

  • npm install for any CDK project generated by agentcore create (caret won't resolve)
  • likely the bundled-distribution smoke test (npm run bundle) and anything else that touches the vended CDK project

Options:

  1. Hold this PR until the companion CDK PR is merged and alpha.29 is published to npm, then rebase.
  2. Split the version bump into a follow-up PR that lands after the publish.
  3. If there's a release-coordination workflow that handles the pin automatically (27ce126 removed one), confirm it still applies here — the removal of that auto-sync is what makes this manual coordination necessary.

"aws-cdk-lib": "^2.248.0",
"constructs": "^10.0.0"
}
Expand Down
19 changes: 19 additions & 0 deletions src/cli/aws/agentcore-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,10 @@ export interface GetOnlineEvalConfigResult {
outputLogGroupName?: string;
/** Sampling percentage from the rule config */
samplingPercentage?: number;
/** Session idle timeout in minutes from rule.sessionConfig (if set) */
sessionTimeoutMinutes?: number;
/** Filter rules from rule.filters (if any) */
filters?: import('../../schema').FilterRule[];
/** Service names from CloudWatch data source config (e.g. "projectName_agentName.DEFAULT") */
serviceNames?: string[];
/** Evaluator IDs referenced by this config */
Expand All @@ -763,6 +767,19 @@ export async function getOnlineEvaluationConfig(

const logGroupName = response.outputConfig?.cloudWatchConfig?.logGroupName;
const samplingPercentage = response.rule?.samplingConfig?.samplingPercentage;
const sessionTimeoutMinutes = response.rule?.sessionConfig?.sessionTimeoutMinutes;
const filters = (response.rule?.filters ?? [])
.map(f => {
if (!f.key || !f.operator || !f.value) return undefined;
const v = f.value;
const value: { stringValue?: string; doubleValue?: number; booleanValue?: boolean } = {};
if ('stringValue' in v && v.stringValue !== undefined) value.stringValue = v.stringValue;
else if ('doubleValue' in v && v.doubleValue !== undefined) value.doubleValue = v.doubleValue;
else if ('booleanValue' in v && v.booleanValue !== undefined) value.booleanValue = v.booleanValue;
else return undefined; // unknown variant — skip
return { key: f.key, operator: f.operator as import('../../schema').FilterOperator, value };
})
.filter((f): f is import('../../schema').FilterRule => f !== undefined);
const serviceNames =
response.dataSourceConfig && 'cloudWatchLogs' in response.dataSourceConfig
? response.dataSourceConfig.cloudWatchLogs?.serviceNames
Expand All @@ -781,6 +798,8 @@ export async function getOnlineEvaluationConfig(
failureReason: response.failureReason,
outputLogGroupName: logGroupName,
samplingPercentage,
...(sessionTimeoutMinutes !== undefined && { sessionTimeoutMinutes }),
...(filters.length > 0 && { filters }),
serviceNames,
evaluatorIds,
};
Expand Down
44 changes: 44 additions & 0 deletions src/cli/commands/import/__tests__/import-online-eval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,50 @@ describe('toOnlineEvalConfigSpec', () => {
expect(result.description).toBeUndefined();
});

it('preserves sessionTimeoutMinutes and filters when present', () => {
const detail: GetOnlineEvalConfigResult = {
configId: 'oec-rich',
configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-rich',
configName: 'Rich',
status: 'ACTIVE',
executionStatus: 'ENABLED',
samplingPercentage: 30,
sessionTimeoutMinutes: 60,
filters: [
{ key: 'userId', operator: 'Equals', value: { stringValue: 'abc' } },
{ key: 'score', operator: 'GreaterThan', value: { doubleValue: 0.5 } },
],
serviceNames: ['agent.DEFAULT'],
evaluatorIds: ['eval-1'],
};

const result = toOnlineEvalConfigSpec(detail, 'Rich', 'agent', ['eval_one']);

expect(result.sessionTimeoutMinutes).toBe(60);
expect(result.filters).toEqual([
{ key: 'userId', operator: 'Equals', value: { stringValue: 'abc' } },
{ key: 'score', operator: 'GreaterThan', value: { doubleValue: 0.5 } },
]);
});

it('omits sessionTimeoutMinutes and filters when absent on the source config', () => {
const detail: GetOnlineEvalConfigResult = {
configId: 'oec-bare',
configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-bare',
configName: 'Bare',
status: 'ACTIVE',
executionStatus: 'ENABLED',
samplingPercentage: 5,
serviceNames: ['agent.DEFAULT'],
evaluatorIds: ['eval-1'],
};

const result = toOnlineEvalConfigSpec(detail, 'Bare', 'agent', ['eval_one']);

expect(result.sessionTimeoutMinutes).toBeUndefined();
expect(result.filters).toBeUndefined();
});

it('throws when sampling percentage is missing', () => {
const detail: GetOnlineEvalConfigResult = {
configId: 'oec-no-sampling',
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/import/import-online-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export function toOnlineEvalConfigSpec(
samplingRate: detail.samplingPercentage,
...(detail.description && { description: detail.description }),
...(detail.executionStatus === 'ENABLED' && { enableOnCreate: true }),
...(detail.sessionTimeoutMinutes !== undefined && { sessionTimeoutMinutes: detail.sessionTimeoutMinutes }),
...(detail.filters && detail.filters.length > 0 && { filters: detail.filters }),
};
}

Expand Down
8 changes: 8 additions & 0 deletions src/cli/operations/dev/web-ui/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@
evaluators: string[];
samplingRate: number;
description?: string;
/** Session idle timeout in minutes (1-1440). Default: 5 */
sessionTimeoutMinutes?: number;
/** Optional filter rules limiting which traces are evaluated. */
filters?: Array<{

Check failure on line 207 in src/cli/operations/dev/web-ui/api-types.ts

View workflow job for this annotation

GitHub Actions / lint

Array type using 'Array<T>' is forbidden. Use 'T[]' instead
key: string;
operator: string;
value: { stringValue?: string; doubleValue?: number; booleanValue?: boolean };
}>;
deploymentStatus?: ResourceDeploymentStatus;
deployed?: DeployedOnlineEvalState;
}
Expand Down
31 changes: 30 additions & 1 deletion src/cli/primitives/OnlineEvalConfigPrimitive.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { findConfigRoot } from '../../lib';
import type { OnlineEvalConfig } from '../../schema';
import type { FilterRule, OnlineEvalConfig } from '../../schema';
import { OnlineEvalConfigSchema } from '../../schema';
import { getErrorMessage } from '../errors';
import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types';
import { cliCommandRun } from '../telemetry/cli-command-run.js';
import { requireTTY } from '../tui/guards/tty';
import { BasePrimitive } from './BasePrimitive';
import { parseFilterFlags } from './filter-flag-parser';
import type { AddResult, AddScreenComponent, RemovableResource } from './types';
import type { Command } from '@commander-js/extra-typings';

Expand All @@ -16,6 +17,8 @@ export interface AddOnlineEvalConfigOptions {
samplingRate: number;
enableOnCreate?: boolean;
endpoint?: string;
sessionTimeoutMinutes?: number;
filters?: FilterRule[];
}

export type RemovableOnlineEvalConfig = RemovableResource;
Expand Down Expand Up @@ -113,6 +116,11 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive<AddOnlineEvalConfig
.option('--sampling-rate <rate>', 'Sampling percentage (0.01-100) [non-interactive]')
.option('--endpoint <name>', 'Runtime endpoint name to scope monitoring [non-interactive]')
.option('--enable-on-create', 'Enable evaluation immediately after deploy [non-interactive]')
.option('--session-timeout-minutes <minutes>', 'Session idle timeout in minutes (1-1440) [non-interactive]')
.option(
'--filter <spec...>',
'Filter rule (repeatable). Format: key=<k>,op=<Operator>,type=<string|double|boolean>,value=<v> [non-interactive]'
)
.option('--json', 'Output as JSON [non-interactive]')
.action(
async (cliOptions: {
Expand All @@ -123,6 +131,8 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive<AddOnlineEvalConfig
samplingRate?: string;
endpoint?: string;
enableOnCreate?: boolean;
sessionTimeoutMinutes?: string;
filter?: string[];
json?: boolean;
}) => {
if (!findConfigRoot()) {
Expand All @@ -149,13 +159,28 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive<AddOnlineEvalConfig
);
}

let sessionTimeoutMinutes: number | undefined;
if (cliOptions.sessionTimeoutMinutes !== undefined) {
const parsed = Number(cliOptions.sessionTimeoutMinutes);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1440) {
throw new Error(
`Invalid --session-timeout-minutes "${cliOptions.sessionTimeoutMinutes}". Must be an integer between 1 and 1440`
);
}
sessionTimeoutMinutes = parsed;
}

const filters = parseFilterFlags(cliOptions.filter);

const result = await this.add({
name: cliOptions.name,
agent: cliOptions.runtime,
evaluators: allEvaluators,
samplingRate,
enableOnCreate: cliOptions.enableOnCreate,
endpoint: cliOptions.endpoint,
...(sessionTimeoutMinutes !== undefined && { sessionTimeoutMinutes }),
...(filters && filters.length > 0 && { filters }),
});

if (!result.success) {
Expand All @@ -171,6 +196,8 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive<AddOnlineEvalConfig
return {
evaluator_count: allEvaluators.length,
enable_on_create: cliOptions.enableOnCreate ?? false,
filter_count: filters?.length ?? 0,
session_timeout_set: sessionTimeoutMinutes !== undefined,
};
});
} else {
Expand Down Expand Up @@ -235,6 +262,8 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive<AddOnlineEvalConfig
samplingRate: options.samplingRate,
...(options.enableOnCreate !== undefined && { enableOnCreate: options.enableOnCreate }),
...(options.endpoint && { endpoint: options.endpoint }),
...(options.sessionTimeoutMinutes !== undefined && { sessionTimeoutMinutes: options.sessionTimeoutMinutes }),
...(options.filters && options.filters.length > 0 && { filters: options.filters }),
};

project.onlineEvalConfigs.push(config);
Expand Down
70 changes: 70 additions & 0 deletions src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,76 @@ describe('OnlineEvalConfigPrimitive', () => {
expect(config.enableOnCreate).toBeUndefined();
});

it('persists sessionTimeoutMinutes when provided', async () => {
mockReadProjectSpec.mockResolvedValue(makeProject());
mockWriteProjectSpec.mockResolvedValue(undefined);

const result = await primitive.add({
name: 'WithTimeout',
agent: 'MyAgent',
evaluators: ['Builtin.GoalSuccessRate'],
samplingRate: 10,
sessionTimeoutMinutes: 30,
});

expect(result.success).toBe(true);
const config = mockWriteProjectSpec.mock.calls[0]![0].onlineEvalConfigs[0];
expect(config.sessionTimeoutMinutes).toBe(30);
});

it('omits sessionTimeoutMinutes when not provided', async () => {
mockReadProjectSpec.mockResolvedValue(makeProject());
mockWriteProjectSpec.mockResolvedValue(undefined);

await primitive.add({
name: 'NoTimeout',
agent: 'MyAgent',
evaluators: ['Builtin.GoalSuccessRate'],
samplingRate: 10,
});

const config = mockWriteProjectSpec.mock.calls[0]![0].onlineEvalConfigs[0];
expect(config.sessionTimeoutMinutes).toBeUndefined();
});

it('persists filters when provided', async () => {
mockReadProjectSpec.mockResolvedValue(makeProject());
mockWriteProjectSpec.mockResolvedValue(undefined);

const filters = [
{ key: 'userId', operator: 'Equals' as const, value: { stringValue: 'abc' } },
{ key: 'score', operator: 'GreaterThan' as const, value: { doubleValue: 0.5 } },
];

const result = await primitive.add({
name: 'WithFilters',
agent: 'MyAgent',
evaluators: ['Builtin.GoalSuccessRate'],
samplingRate: 10,
filters,
});

expect(result.success).toBe(true);
const config = mockWriteProjectSpec.mock.calls[0]![0].onlineEvalConfigs[0];
expect(config.filters).toEqual(filters);
});

it('omits filters when an empty array is provided', async () => {
mockReadProjectSpec.mockResolvedValue(makeProject());
mockWriteProjectSpec.mockResolvedValue(undefined);

await primitive.add({
name: 'EmptyFilters',
agent: 'MyAgent',
evaluators: ['Builtin.GoalSuccessRate'],
samplingRate: 10,
filters: [],
});

const config = mockWriteProjectSpec.mock.calls[0]![0].onlineEvalConfigs[0];
expect(config.filters).toBeUndefined();
});

it('supports multiple evaluators including ARNs', async () => {
mockReadProjectSpec.mockResolvedValue(makeProject());
mockWriteProjectSpec.mockResolvedValue(undefined);
Expand Down
99 changes: 99 additions & 0 deletions src/cli/primitives/__tests__/filter-flag-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { parseFilterFlag, parseFilterFlags } from '../filter-flag-parser';
import { describe, expect, it } from 'vitest';

describe('parseFilterFlag', () => {
it('parses a string filter', () => {
expect(parseFilterFlag('key=userId,op=Equals,type=string,value=abc')).toEqual({
key: 'userId',
operator: 'Equals',
value: { stringValue: 'abc' },
});
});

it('parses a double filter', () => {
expect(parseFilterFlag('key=score,op=GreaterThan,type=double,value=0.75')).toEqual({
key: 'score',
operator: 'GreaterThan',
value: { doubleValue: 0.75 },
});
});

it('parses a boolean filter (true)', () => {
expect(parseFilterFlag('key=isPremium,op=Equals,type=boolean,value=true')).toEqual({
key: 'isPremium',
operator: 'Equals',
value: { booleanValue: true },
});
});

it('parses a boolean filter (false)', () => {
expect(parseFilterFlag('key=isPremium,op=NotEquals,type=boolean,value=false')).toEqual({
key: 'isPremium',
operator: 'NotEquals',
value: { booleanValue: false },
});
});

it('keeps "true" as a string when type=string', () => {
expect(parseFilterFlag('key=k,op=Equals,type=string,value=true')).toEqual({
key: 'k',
operator: 'Equals',
value: { stringValue: 'true' },
});
});

it('accepts fields in any order', () => {
expect(parseFilterFlag('value=abc,type=string,op=Contains,key=label')).toEqual({
key: 'label',
operator: 'Contains',
value: { stringValue: 'abc' },
});
});

it('throws on unknown operator', () => {
expect(() => parseFilterFlag('key=k,op=StartsWith,type=string,value=x')).toThrow(/op/);
});

it('throws on unknown type', () => {
expect(() => parseFilterFlag('key=k,op=Equals,type=int,value=1')).toThrow(/type/);
});

it('throws on missing required field', () => {
expect(() => parseFilterFlag('key=k,op=Equals,type=string')).toThrow(/value/);
});

it('throws on duplicate keys', () => {
expect(() => parseFilterFlag('key=k,key=other,op=Equals,type=string,value=v')).toThrow(/Duplicate/);
});

it('throws on invalid double value', () => {
expect(() => parseFilterFlag('key=k,op=Equals,type=double,value=notanumber')).toThrow(/double/);
});

it('throws on invalid boolean value', () => {
expect(() => parseFilterFlag('key=k,op=Equals,type=boolean,value=yes')).toThrow(/boolean/);
});

it('throws on empty input', () => {
expect(() => parseFilterFlag('')).toThrow();
expect(() => parseFilterFlag(' ')).toThrow();
});

it('throws on syntactic garbage', () => {
expect(() => parseFilterFlag('not_a_kv_pair')).toThrow();
});
});

describe('parseFilterFlags', () => {
it('returns undefined for missing/empty input', () => {
expect(parseFilterFlags(undefined)).toBeUndefined();
expect(parseFilterFlags([])).toBeUndefined();
});

it('parses multiple filters', () => {
const out = parseFilterFlags(['key=a,op=Equals,type=string,value=x', 'key=b,op=GreaterThan,type=double,value=2']);
expect(out).toHaveLength(2);
expect(out![0]!.key).toBe('a');
expect(out![1]!.value).toEqual({ doubleValue: 2 });
});
});
Loading
Loading