developer
  • Joined on 2025-12-29

Knurz.Harness.Client (0.13.1)

Published 2026-11-13 10:47:25 +01:00 by developer

Installation

dotnet nuget add source --name developer --username your_username --password your_token 
dotnet add package --source developer --version 0.13.1 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, SendAnswerAsync (an answer that names its pause, so it may be sent again, lesson L64) and SendToolResultsAsync, SendInputAsync, SendApprovalsAsync, SendMessageAsync (steer; with an id from NewMessageId a message may be sent again and its Steered event names the id, lesson L75), CancelAsync, GetRunAsync (status and the latest event's EventSeq), GetStateAsync, ResumeRunAsync, GetStatusAsync, GetToolsAsync and GetSkillsAsync (catalogs for a user interface), DownloadFileAsync (files a tool made for the user, listed in ToolFinished.Files). ToolProgress events say where a long tool call stands: a step of a total, a place in a queue, a small preview (lesson L79)
Timeouts HarnessTimeouts: 100 s for a call that does not stream, 45 s of silence on an event stream (the host sends a keep-alive every 15 s); a call past its time fails with a HarnessException around a TimeoutException
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. CallerTool.CreateWithOutcome<T> lets the tool return a CallerToolOutcome with IsError for a call it refuses
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

How RunDriver follows a run (lesson L63):

  • Before it starts a run, it checks that the spec's caller tools, questions and approvals all have a handler.
  • A dropped, silent or gateway-refused (502, 503, 504) event stream is read on after the last event the driver has handled, with waits of 0, 0.5, 1, 2 and 5 seconds. A stream that ends before the run's end makes the driver read the run's status; following a run that has stopped returns its stop.
  • Before it answers a pause, the driver reads the run's status: a run followed from an earlier event (after an app restart, FollowAsync(runId, handlers, 0)) gets only the pause it waits at answered, not those it has left.
  • An answer names its pause. One whose sending fails is sent again with the same waits; the host refuses it once the run has left the pause (409 pause_answered), so a pause is never answered twice (lesson L64).
  • A failure of the app's own code (OnEvent, a handler, the checkpoint store) ends the follow with that failure; it is not taken for a lost connection.
  • The caller tools of one pause run one after another, in the order of the model's reply.
  • Cancelling the token stops the following, not the run: the run goes on in the host up to its limits. An app that wants it to end calls CancelAsync; one that needs the run's id for that before the first event starts the run with StartRunAsync and drives it with FollowAsync.
  • A start that survives a lost answer and the app's own restart (lesson L74): take an id (HarnessClient.NewRunId()) and the host's BootId (GetStatusAsync), store both, then call StartRunAsync(spec, runId, bootId); call it again with the same three after a restart. It returns the run the host has, or sends the start again when the same host never got it; when the host restarted meanwhile it throws HarnessException with Code == HarnessClient.StartOutcomeUnknown: the run may have started and is gone, so the app neither counts on it nor on its absence. StartRunAsync(spec) does the same within one call.
  • A run that acts as its user at an MCP server (lesson L76): StartRunAsync(spec, runId, bootId, mcpHeaders) with new McpHeaders().Set("azure-devops", "Authorization", $"Bearer {token}"), for a server whose host configuration names the header (caller_headers). The host keeps the headers in memory only, so set RunHandlers.LoadMcpHeaders too: the RunDriver reads them again for every resume from a checkpoint, which never holds them. McpHeaders.ToString() names servers and headers, never a value.
  • Files a run brings for its tools (lesson L78): StartRunAsync(spec, runId, bootId, mcpHeaders, files) with new RunFiles().Add("att_1", "photo.png", "image/png", bytes), and a FilePart("att_1", "photo.png", "image/png") in the message the file belongs to, so the model reads its id and can pass it to a tool that takes files. A file a tool made has its id in ToolFinished.Files and in the line the model read; an app that keeps the file brings it to a later run under that id. The host keeps the files with the run only and a checkpoint stores none, so RunHandlers.LoadFiles supplies them again when the driver resumes a run.
  • A message to a running run that may be sent again (lesson L75): take an id (HarnessClient.NewMessageId()), store it with the message, then call SendMessageAsync(runId, text, messageId); call it again with the same id after a restart. The run takes the id once, and the host answers a repeat as accepted whatever the run does now; the client itself sends again after a lost answer. The Steered event that says the run read the message carries MessageId, so receipts match by id. run_not_running means the run neither works nor took the message; message_conflict, that it took the id with another text.

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 on its HttpClient (an event stream stays open while a run is paused); RequestTimeout (default 100 s) and StreamIdleTimeout (default 45 s) limit the calls instead. 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 and answers sent again (warning, with the exception), resumes and stops (information), pauses and stored checkpoints (debug).
  • The options are checked when the app starts: without BaseUrl, or with a timeout that is not positive, it does not start.
  • AddHarnessClient returns the IHttpClientBuilder, e.g. for a resilience handler. Give it no retries for POST requests: a retried start starts a second run. The driver sends answers to pauses again itself, safely (lesson L64).
  • Without this package the client is a plain typed client too: services.AddHttpClient<HarnessClient>(http => http.BaseAddress = ...) builds it with the default timeouts; set the token and an infinite HttpClient.Timeout yourself. A test that routes the client of AddHarnessClient to a fake host configures the named client, services.AddHttpClient(nameof(HarnessClient)).ConfigurePrimaryHttpMessageHandler(...): AddHttpClient<HarnessClient>() would register the plain activation again, without the options' timeouts.

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, each run on its own, so that one failure stops no other.
await Task.WhenAll((await store.ListAsync()).Select(async checkpoint =>
{
    try
    {
        await driver.ResumeAsync(checkpoint, handlers);
    }
    catch (Exception error) when (error is HarnessException or InvalidOperationException)
    {
        logger.LogWarning(error, "Run {RunId} could not be continued.", checkpoint.RunId);
    }
}));
  • 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.
  • An answer stored for the pause the run still waits at (the app went down before sending it) is sent as stored when the driver follows the run again: the caller tools do not run a second time.
  • ResumeAsync reads the run first. If the host still holds it (only the app restarted), the driver follows it there; a pause it still waits at gets the stored answer through the answer endpoint, or the handlers' answer if none was stored.
  • If the host does not know the run, ResumeAsync resumes it from the checkpoint: an open pause with the handlers' answer, an answered one with the answer as it was stored. Then the caller tools do not run again, and the host does not repeat calls of that reply that change something, because a lost attempt may have carried them out; the model learns that their effect is unknown (lesson L46). An answer the host refused with 404 run_not_found at its first attempt reached no host, so it is resumed as a first attempt.
  • If the host no longer knows a run the driver follows (it restarted, or it let the paused run go), the driver continues it from its checkpoint by itself, at most as often in a row as it has reconnect waits.
  • What survives: a run that has paused, from its last pause. A run that has not paused yet has no checkpoint and is lost with the host; the app starts it again.
  • After a resume, the events after the pause come again with the same sequence numbers and other content. An app that shows events drops what it showed after the pause (the chat does this by the sequence numbers).
  • A pause that no handler answers cancels the run and deletes its checkpoint.
  • A caller tool runs once per call, unless the app goes down after running it and before storing the answer: then it runs again for the same call.Id when the run is continued. A tool that changes something uses call.Id as its idempotency key.
  • 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 reconnects within its waits, gives up after the last one, and the app continues the run 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-13 10:47:25 +01:00
10
Florian Honegger
115 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