Knurz.Harness.Client.DependencyInjection (0.13.1)
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.DependencyInjectionAbout this package
Registers Knurz.Harness.Client with dependency injection: HarnessClient through IHttpClientFactory, its options from configuration, and RunDriver with the app's logger and checkpoint store.
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 withStartRunAsyncand drives it withFollowAsync. - A start that survives a lost answer and the app's own restart (lesson L74): take an id (
HarnessClient.NewRunId()) and the host'sBootId(GetStatusAsync), store both, then callStartRunAsync(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 throwsHarnessExceptionwithCode == 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)withnew 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 setRunHandlers.LoadMcpHeaderstoo: theRunDriverreads 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)withnew RunFiles().Add("att_1", "photo.png", "image/png", bytes), and aFilePart("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 inToolFinished.Filesand 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, soRunHandlers.LoadFilessupplies 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 callSendMessageAsync(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. TheSteeredevent that says the run read the message carriesMessageId, so receipts match by id.run_not_runningmeans 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/" } }
HarnessClientis a typed client ofIHttpClientFactorywith the host's URL, the bearer token and no timeout on itsHttpClient(an event stream stays open while a run is paused);RequestTimeout(default 100 s) andStreamIdleTimeout(default 45 s) limit the calls instead. Keep the token out of checked-in files: user secrets, the environment variableHarness__Tokenor a vault.RunDriveris transient and takes the app'sILogger<RunDriver>and, if one is registered, itsIRunCheckpointStore. 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. AddHarnessClientreturns theIHttpClientBuilder, 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 infiniteHttpClient.Timeoutyourself. A test that routes the client ofAddHarnessClientto 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.
ResumeAsyncreads 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,
ResumeAsyncresumes 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 with404 run_not_foundat 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.Idwhen the run is continued. A tool that changes something usescall.Idas 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 astoppedevent; 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.