developer
  • Joined on 2025-12-29

Knurz.Harness.Client (0.8.0)

Published 2026-11-09 18:59:40 +01:00 by developer

Installation

dotnet nuget add source --name developer --username your_username --password your_token 
dotnet add package --source developer --version 0.8.0 Knurz.Harness.Client

About this package

.NET client for the harness host: typed run specs and events, an event stream that resumes after a dropped connection, caller tools, typed finish results and runs across a host restart.

Harness.Client

The .NET client of the harness host: typed run specs, messages and events, an event stream that resumes after a dropped connection, caller tools carried out in .NET, and typed finish results. .NET 10; its one dependency is Microsoft.Extensions.Logging.Abstractions.

The NuGet packages are Knurz.Harness.Client and Knurz.Harness.Client.DependencyInjection (registration for apps with dependency injection) in the Gitea feed; map them to that feed as the root README shows.

The host runs the agent loop (docs/contract.md); this library is how a .NET app drives it. The C# types follow the host's JSON schemas in contract, which a test in the host keeps current; the tests here compare every C# type with them, so a field cannot change on one side only.

A run with a caller tool and a typed result

using Harness.Client;

using var harness = HarnessClient.Create(new Uri("http://127.0.0.1:18090/"), token);

var lookup = CallerTool.Create<LookupArguments>(
    "lookup", "Looks up the value of a key.",
    async (arguments, ct) => await store.GetAsync(arguments.Key, ct),
    mutates: false);

var spec = new RunSpec
{
    Model = "qwen/qwen3-coder",
    SystemPrompt = "You look things up and report them.",
    Messages = [UserMessage.FromText("Look up key a and report it.")],
    Limits = new RunLimits { MaxToolCalls = 20, MaxSeconds = 300 },
    End = EndMode.FinishTool,
    FinishTool = ToolSpec.For<Report>("report", "The result."),
    CallerTools = CallerTool.Specs([lookup]),
};

var result = await new RunDriver(harness).RunAsync(spec, new RunHandlers
{
    CallerTools = [lookup],
    OnEvent = (ev, ct) => { Console.WriteLine(ev.Type); return ValueTask.CompletedTask; },
});
Report report = result.FinishAs<Report>();   // checked against the schema by the host (lesson L42)

record LookupArguments(string Key);
record Report(string Summary, int Count);

What it covers

Need API
Every host endpoint HarnessClient: StartRunAsync, EventsAsync, SendToolResultsAsync, SendInputAsync, SendApprovalsAsync, SendMessageAsync (steer), CancelAsync, GetRunAsync, GetStateAsync, ResumeRunAsync, GetStatusAsync, GetToolsAsync and GetSkillsAsync (catalogs for a user interface), DownloadFileAsync (files a tool made for the user, listed in ToolFinished.Files)
A whole run with its pauses RunDriver.RunAsync / FollowAsync with RunHandlers (events, caller tools, questions, approvals)
Caller tools with typed arguments CallerTool.Create<T>: the schema comes from T; arguments that do not fit go back to the model as an error
Typed results ToolSpec.For<T> as finish tool, RunResult.FinishAs<T>()
A run across a host or app restart RunDriver with an IRunCheckpointStore (FileRunCheckpointStore, InMemoryRunCheckpointStore or your database); ResumeAsync continues a stored run, see below
The next turn from a run's messages RunState.Messages into the next RunSpec.Messages
Host refusals HarnessException with Status, Code (e.g. run_not_running) and Detail
Event types of newer hosts arrive as UnknownEvent with their JSON

Before it starts a run, RunDriver checks that the spec's caller tools, questions and approvals all have a handler. A dropped event stream is read on after the last sequence number seen (waits of 0, 0.5, 1, 2 and 5 seconds); a pause is never answered twice.

In an app with dependency injection

Knurz.Harness.Client.DependencyInjection registers the client for ASP.NET Core and worker services:

builder.Services.AddSingleton<IRunCheckpointStore>(new FileRunCheckpointStore("checkpoints")); // optional
builder.Services.AddHarnessClient();   // reads the section "Harness"; or AddHarnessClient(options => ...)

// in a service:
public sealed class ReviewWorker(RunDriver driver) { /* driver.RunAsync(spec, handlers) */ }
{ "Harness": { "BaseUrl": "http://harness-host:8080/" } }
  • HarnessClient is a typed client of IHttpClientFactory with the host's URL, the bearer token and no timeout (an event stream stays open while a run is paused). Keep the token out of checked-in files: user secrets, the environment variable Harness__Token or a vault.
  • RunDriver is transient and takes the app's ILogger<RunDriver> and, if one is registered, its IRunCheckpointStore. It logs reconnects (warning), resumes and stops (information), pauses and stored checkpoints (debug).
  • The options are checked when the app starts: without BaseUrl it does not start.
  • AddHarnessClient returns the IHttpClientBuilder, e.g. for a resilience handler. Give it no retries for POST requests: the host's POSTs are not idempotent, and a retried start starts a second run.

Across a host restart

The host keeps runs in memory. With a checkpoint store, a run survives a restart of the host (a deploy, a crash) and of the app:

var store = new FileRunCheckpointStore("checkpoints");   // or your database behind IRunCheckpointStore
var driver = new RunDriver(harness, checkpoints: store);
var result = await driver.RunAsync(spec, handlers);

// When the app starts: continue what was open.
foreach (var checkpoint in await store.ListAsync())
{
    await driver.ResumeAsync(checkpoint, handlers);
}
  • At every pause the driver stores the state, then decides the answer (runs the caller tools, asks the user, decides approvals), stores the answer before it sends it, and forgets the run when it stops.
  • ResumeAsync answers an open pause with the handlers. An answered pause is sent again as it was stored: the caller tools do not run again, and the host does not repeat calls of that reply that change something, because the lost attempt may have carried them out; the model learns that their effect is unknown (lesson L46).
  • If the host still holds the run (only the app restarted), ResumeAsync follows it there. If a restarted host no longer knows a run the driver follows, the driver continues it from its checkpoint by itself.
  • What happened after the last pause is lost with the host: the run repeats it from the pause. Read-only work does not matter; a tool that changes something without asking for approval may run twice, so such tools need approval (which makes a pause) or must be idempotent.
  • A host that shuts down closes its event streams after HARNESS_HOST_SHUTDOWN_SECONDS (10) without a stopped event; a driver gives up after its reconnect waits and the app resumes later.

Build and test

dotnet test clients/dotnet/Harness.Client.slnx
HARNESS_DOTNET_TESTS=1 uv run pytest packages/harness-host/tests/test_dotnet_client.py

The second drives Harness.Client.Smoke against a real host with a scripted model. After a change of the host's types: uv run python -m harness_host.contract clients/dotnet/contract.

The package version equals the Python packages' version (0.4.0a3 is 0.4.0-alpha.3); a test keeps them equal.

Dependencies

ID Version Target Framework
Microsoft.Extensions.Logging.Abstractions 10.0.0 net10.0
Details
NuGet
2026-11-09 18:59:40 +01:00
6
Florian Honegger
88 KiB
Assets (2)
Versions (18) View all
0.13.1 2026-11-13
0.13.0 2026-11-13
0.12.0 2026-11-13
0.11.1 2026-11-12
0.11.0 2026-11-12