// Copyright 2026 TesterArmy. // Modified by Dario Kondratiuk. // SPDX-License-Identifier: Apache-2.0 using System.Text.Json; using E2E.Internal; namespace E2E; /// One turn of the agent model. Tool calls are executed by the agent, never by the model itself. public interface IAgentModel { /// Provider and model id, printed in the run summary. Changing it does not miss the replay cache. string Name { get; } Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken); } public sealed class ModelRequest { public required string System { get; init; } public required IReadOnlyList Messages { get; init; } public required IReadOnlyList Tools { get; init; } /// /// The agent's provider options, keyed by provider, for the model to pass on. Null when none are set. /// adds the entry under its provider name to the request body. /// public IReadOnlyDictionary? ProviderOptions { get; init; } /// /// Upstream's ProviderHints.markLatest, which only the act tool loop calls: the next turn repeats everything up /// to the newest message, so marks that message for caching. Judgments and extractions leave it off. /// public bool MarkLatest { get; init; } /// /// Upstream's ModelCall.schema: a closed response grammar the provider is asked to fill as structured output. When it is /// set, is empty and the model answers with one JSON object that matches it, as ; /// the agent validates that text itself. Null for a turn that offers tools. A custom must handle it. /// public JsonElement? Schema { get; init; } /// Upstream's ModelCall.schemaName: the name sent with , for the providers that name a structured output. public string? SchemaName { get; init; } /// Redacts the secrets of the step from a provider failure that quotes the response body. internal Redactor Redactor { get; init; } = Redactor.None; } public sealed class ModelMessage { public required string Role { get; init; } public string? Content { get; init; } public IReadOnlyList? ToolCalls { get; init; } public string? ToolCallId { get; init; } public string? Name { get; init; } /// The reasoning the model returned with this turn, sent back as it came: encrypted items for , signed thinking blocks for , thought parts for , the reasoningContent blocks for , the reasoning text as a JSON string for . public IReadOnlyList? ReasoningItems { get; init; } /// The images that go with a user turn, after its text. A provider sends them as the turn's image parts; on any other role they are not sent. public IReadOnlyList? Images { get; init; } } /// An image a model reads: the encoded bytes of a screenshot, already sized for the model. /// The encoded image. /// The media type of , such as image/png. /// Width in image pixels. /// Height in image pixels. public sealed record ModelImage(byte[] Data, string MediaType, int Width, int Height); public sealed class ModelTool { public required string Name { get; init; } public required string Description { get; init; } public required JsonElement Parameters { get; init; } } public sealed class ModelToolCall { public required string Id { get; init; } public required string Name { get; init; } public required JsonElement Arguments { get; init; } } public sealed class ModelUsage { public int InputTokens { get; init; } public int OutputTokens { get; init; } /// Input tokens the provider served from its prompt cache, when it reports the split. A part of . public int? CacheReadTokens { get; init; } /// Input tokens the provider wrote to its prompt cache, when it reports the split. A part of . public int? CacheWriteTokens { get; init; } /// The billed cost of the call in US dollars, when the provider reports one. public double? EstimatedCostUsd { get; init; } } public sealed class ModelResponse { public string? Content { get; init; } public IReadOnlyList ToolCalls { get; init; } = []; public ModelUsage? Usage { get; init; } /// The reasoning of the turn that the model can take back, to replay on the next turn. Empty when the model gave none. public IReadOnlyList ReasoningItems { get; init; } = []; /// The AI SDK finish reason that matters to the agent: when the provider stopped the turn because it refused the request. Null for any other turn. public string? FinishReason { get; init; } /// The provider's own word for the stop, such as refusal, when it sent one. Provider text: redact it before it reaches a message. public string? RawFinishReason { get; init; } /// The finish reason of a turn the provider refused (upstream's content-filter). public const string ContentFilter = "content-filter"; } /// Builds the tool calls a (or a test) returns. public static class ModelResponses { public static ModelResponse Done(string status, string summary, string? code = null) { return Call("done", code is null ? new { status, summary } : (object)new { status, summary, code }); } /// /// A judge's answer: one agent-judgment-2 object as the content of the turn. is /// holds, fails, or inconclusive. /// public static ModelResponse Judgment(string verdict, string explanation) { return new ModelResponse { Content = JsonSerializer.Serialize(new { protocolVersion = "agent-judgment-2", explanation, verdict }, JsonDefaults.Options), }; } public static ModelResponse Tap(string role, string name) { return Call("tap", new { role, name }); } public static ModelResponse Fill(string role, string name, string value) { return Call("fill", new { role, name, value }); } public static ModelResponse Call(string name, object arguments) { ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(arguments); return new ModelResponse { ToolCalls = [ new ModelToolCall { Id = "call_" + name, Name = name, Arguments = JsonSerializer.SerializeToElement(arguments, JsonDefaults.Options), }, ], }; } } /// /// Returns whatever the callback decides for each request. Records every request. A callback that serves both the act loop /// and the judge may answer a judgment (a request with a ) with : /// passed reads as holds, a failed without the code ASSERTION_INCONCLUSIVE as fails, and a /// blocked or inconclusive one as inconclusive. answers it directly. /// public sealed class ScriptedModel : IAgentModel { private readonly Func _next; public ScriptedModel(Func next) { ArgumentNullException.ThrowIfNull(next); _next = next; } public string Name => "scripted"; public int CallCount { get; private set; } public List Requests { get; } = []; public Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); CallCount++; Requests.Add(request); var response = _next(request); return Task.FromResult(request.Schema is null ? response : AsJudgment(response)); } private static ModelResponse AsJudgment(ModelResponse response) { var done = response.ToolCalls.FirstOrDefault(call => string.Equals(call.Name, "done", StringComparison.Ordinal)); if (done is null || response.Content is not null) { return response; } var status = Args.String(done.Arguments, "status"); var code = Args.String(done.Arguments, "code"); var verdict = status switch { "passed" => "holds", "failed" when code != "ASSERTION_INCONCLUSIVE" => "fails", _ => "inconclusive", }; return new ModelResponse { Content = ModelResponses.Judgment(verdict, Args.String(done.Arguments, "summary") ?? "").Content, Usage = response.Usage }; } } /// The input bounds of upstream's agent/model/adapter.ts, which the observation budget and the judgment pre-flight share. internal static class ModelAdapter { /// Side of the square patch tile-based vision encoders consume an image in. private const int ImageTilePx = 28; /// /// Conservative token upper bound. UTF-8 byte length bounds every byte-level tokenizer from above, so it is used when a provider /// reports no usage and for the pre-flight ceiling. Every budget that mixes with this one (reserves, ledger size, observation size) /// is therefore in the same byte-scaled units, not in real tokens. /// public static int TokenUpperBound(string text) => System.Text.Encoding.UTF8.GetByteCount(text); /// /// Conservative token bound for one image: vision providers bill images by fixed-size patches rather than bytes, so the count of /// patches covering the image bounds every such encoder from above. /// public static int ImageTokenUpperBound(int width, int height) => (int)Math.Ceiling(Math.Max(1, width) / (double)ImageTilePx) * (int)Math.Ceiling(Math.Max(1, height) / (double)ImageTilePx); }