// 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);
}