Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,13 @@ public async Task<List<AgentTestSuite>> ListSuites([FromQuery] string? agentId)
=> await _repo.ListSuitesAsync(agentId);

[HttpPost("suites")]
public async Task<AgentTestSuite> CreateSuite([FromBody] AgentTestSuiteUpsertRequest request)
public async Task<ActionResult<AgentTestSuite>> CreateSuite([FromBody] AgentTestSuiteUpsertRequest request)
{
if (ValidateParallelism(request.MaxParallelCases) is { } parallelError)
{
return BadRequest(parallelError);
}

var suite = new AgentTestSuite();
ApplySuite(suite, request);

Expand Down Expand Up @@ -95,6 +100,11 @@ public async Task<ActionResult<AgentTestSuite>> UpdateSuite(string id, [FromBody
request.Name = suite.Name;
}

if (ValidateParallelism(request.MaxParallelCases) is { } parallelError)
{
return BadRequest(parallelError);
}

ApplySuite(suite, request);

await _repo.UpsertSuiteAsync(suite);
Expand Down Expand Up @@ -577,6 +587,11 @@ public async Task<ActionResult<AgentTestRun>> TriggerRun(
return BadRequest(modelError);
}

if (ValidateParallelism(request?.MaxParallelCases) is { } parallelError)
{
return BadRequest(parallelError);
}

if (!suite.Enabled)
{
// The suite's own Enabled flag is advertised in the design as a way to turn a whole
Expand All @@ -598,6 +613,9 @@ public async Task<ActionResult<AgentTestRun>> TriggerRun(
// Empty list and null mean the same thing downstream ("agent's own LlmConfig"), so
// normalise to null rather than persisting an empty array that reads like a choice.
Models = request?.Models is { Count: > 0 } ? request.Models : null,
// Zero means "the suite decides" -- AgentTestRunExecutor resolves it against the suite
// as the run starts and writes the figure it settled on back onto this document.
MaxParallelCases = request?.MaxParallelCases ?? 0,
// This controller action is the only place in the whole trigger-to-execute path that
// ever runs inside a real HTTP request -- AgentTestRunQueue's background loop has no
// HttpContext at all, so if the triggering identity isn't captured here it can never
Expand Down Expand Up @@ -712,6 +730,17 @@ public async Task<ActionResult<List<string>>> GetMockTargets([FromQuery] string?
return duplicate == null ? null : $"model '{duplicate.Key}' is listed more than once";
}

/// <summary>
/// Rejected rather than clamped, for a suite and for a run alike: a stored 100 that every run
/// silently reads as 16 is a setting that lies about itself every time someone opens it, and a
/// caller asking for 100 cases at a time has misunderstood what the harness will do to the
/// provider's rate limit.
/// </summary>
private static string? ValidateParallelism(int? maxParallelCases)
=> maxParallelCases is { } value && (value < 1 || value > AgentTestConcurrency.Max)
? $"'maxParallelCases' has to be between 1 and {AgentTestConcurrency.Max}"
: null;

private static void ApplySuite(AgentTestSuite suite, AgentTestSuiteUpsertRequest request)
{
suite.AgentId = request.AgentId;
Expand All @@ -728,6 +757,8 @@ private static void ApplySuite(AgentTestSuite suite, AgentTestSuiteUpsertRequest
suite.ExtraAllowedFunctions = request.ExtraAllowedFunctions ?? [];
suite.ForceBlockedFunctions = request.ForceBlockedFunctions ?? [];
suite.CaseTimeoutSeconds = request.CaseTimeoutSeconds;
// Null means the body omitted it -- keep what the suite has, like Enabled above.
suite.MaxParallelCases = request.MaxParallelCases ?? suite.MaxParallelCases;
}

private static void ApplyCase(AgentTestCase testCase, AgentTestCaseUpsertRequest request)
Expand Down
17 changes: 17 additions & 0 deletions src/Plugins/BotSharp.Plugin.AgentTesting/Models/AgentTestDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ public class AgentTestSuiteUpsertRequest
public List<string> ExtraAllowedFunctions { get; set; } = [];
public List<string> ForceBlockedFunctions { get; set; } = [];
public int CaseTimeoutSeconds { get; set; } = 120;

/// <summary>
/// Cases to execute at the same time, 1 to <see cref="AgentTestConcurrency.Max"/>. Nullable for
/// the same reason <see cref="Enabled"/> is: a partial PUT that omits it must leave the suite's
/// value alone rather than resetting it to the default.
/// </summary>
public int? MaxParallelCases { get; set; }
}

/// <summary>
Expand Down Expand Up @@ -211,6 +218,16 @@ public class AgentTestRunTriggerRequest
/// and every AgentTestCaseResult records which model produced it.
/// </summary>
public List<TestModel>? Models { get; set; }

/// <summary>
/// Cases to execute at the same time for this run only, 1 to
/// <see cref="AgentTestConcurrency.Max"/>; null falls back to the suite's own setting.
///
/// Per run as well as per suite because the reason to change it is usually about the moment
/// rather than the suite: turning it down to 1 to read a flaky case's logs without three other
/// cases interleaved in them, or because the provider is already busy with something else.
/// </summary>
public int? MaxParallelCases { get; set; }
}

/// <summary>
Expand Down
13 changes: 13 additions & 0 deletions src/Plugins/BotSharp.Plugin.AgentTesting/Models/AgentTestRun.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ public class AgentTestRun : MongoBase
/// </summary>
public List<TestModel>? Models { get; set; }

/// <summary>
/// Cases this run executed at the same time. Written by the executor as the run starts, from
/// the trigger request's value or the suite's -- a record of what actually happened, not a
/// request.
///
/// It belongs on the run because the performance figures below are only comparable between two
/// runs that were produced under the same concurrency: a case's own latency includes whatever
/// queueing the other in-flight cases caused at the provider, so a P95 from a four-at-a-time run
/// and one from a serial run are different measurements. Zero on a run document written before
/// this field existed, which reads as "unknown, it ran serially".
/// </summary>
public int MaxParallelCases { get; set; }

public int TotalCount { get; set; }
public int PassedCount { get; set; }
public int FailedCount { get; set; }
Expand Down
48 changes: 48 additions & 0 deletions src/Plugins/BotSharp.Plugin.AgentTesting/Models/AgentTestSuite.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,54 @@ public class AgentTestSuite : MongoBase

public int CaseTimeoutSeconds { get; set; } = 120;

/// <summary>
/// How many cases of this suite execute at the same time. One run's cases are independent by
/// construction -- each one opens its own conversation and gets its own DI scope, and the mock
/// seam is keyed by conversation id -- so the only thing serial execution ever bought was a
/// gentler load on the model provider.
///
/// Wall clock is what this costs: a case spends nearly all of its time waiting on the model, so
/// a suite of forty 40-second cases takes half an hour serially and a few minutes at four at a
/// time. Four rather than "as many as there are cases" because the limit that bites first is the
/// provider's rate limit, and a 429 storm turns a slow run into a failed one.
///
/// A stored suite written before this field existed has no value for it, which deserialises to
/// this default -- so existing suites speed up without being touched. Set it to 1 for a suite
/// whose cases must not overlap.
/// </summary>
public int MaxParallelCases { get; set; } = AgentTestConcurrency.Default;

public DateTime CreateDate { get; set; } = DateTime.UtcNow;
public DateTime UpdateDate { get; set; } = DateTime.UtcNow;
}

/// <summary>
/// The one place that decides how many cases of a run execute at the same time, so the suite
/// default, the per-run override and the API's validation message can never drift apart.
/// </summary>
public static class AgentTestConcurrency
{
/// <summary>Cases at a time when neither the run nor the suite says otherwise.</summary>
public const int Default = 4;

/// <summary>
/// The highest value accepted. Not a technical ceiling -- the harness would happily open
/// thirty conversations -- but past a handful the provider's rate limit, not the host, decides
/// what happens, and a run that fails on 429s is worse than a slow one. Raise it deliberately.
/// </summary>
public const int Max = 16;

/// <summary>
/// The effective degree of parallelism for a run: the run's own value when it has one, else the
/// suite's, else <see cref="Default"/>, clamped to [1, <see cref="Max"/>].
///
/// Zero and negative mean "not set" rather than "no parallelism": that is what a run document
/// written before this field existed deserialises to, and treating it as 0 would stop such a
/// run from executing anything at all.
/// </summary>
public static int Resolve(int runValue, int suiteValue)
{
var chosen = runValue > 0 ? runValue : suiteValue > 0 ? suiteValue : Default;
return Math.Clamp(chosen, 1, Max);
}
}
Loading
Loading