Knurz.Harness.Client.DependencyInjection (0.5.0-alpha.1)
Installation
dotnet nuget add source --name developer --username your_username --password your_token dotnet add package --source developer --version 0.5.0-alpha.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, SendToolResultsAsync, SendInputAsync, SendApprovalsAsync, SendMessageAsync (steer), CancelAsync, GetRunAsync, GetStateAsync, ResumeRunAsync, GetStatusAsync |
| 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/" } }
HarnessClientis a typed client ofIHttpClientFactorywith 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 variableHarness__Tokenor a vault.RunDriveris transient and takes the app'sILogger<RunDriver>and, if one is registered, itsIRunCheckpointStore. It logs reconnects (warning), resumes and stops (information), pauses and stored checkpoints (debug).- The options are checked when the app starts: without
BaseUrlit does not start. AddHarnessClientreturns theIHttpClientBuilder, 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.
ResumeAsyncanswers 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),
ResumeAsyncfollows 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 astoppedevent; 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.