Runners, deciders and adapters¶
Three more kinds of contribution let a plugin change how work is executed or observed, without ever implementing a control itself: controls live on hook points, and these contributions only make sure the right hooks fire.
Runners¶
A runner executes a task: runner: { use: acme.flow, ... }. The harness owns the run around it (idempotency, deadlines, run.start, input.before, output.before, approvals, run.end); the runner does the work in between, making every model and tool call through the run's context so each is governed.
from collections.abc import Mapping
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, JsonValue
import harnessweaver as hw
class TwoPassConfig(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
critique_prompt: str = Field(description="A prompt reference, 'name@version'.")
@hw.runner("two_pass", reaches=["model.before", "model.after"], config=TwoPassConfig)
class TwoPass:
"""Draft an answer, then improve it once from the model's own critique."""
def __init__(self, config: TwoPassConfig) -> None:
self.critique_prompt = config.critique_prompt
async def execute(self, ctx: hw.RunContext, input: Any, resume: Mapping[str, JsonValue] | None) -> Any:
s = hw.Session.for_run(ctx)
draft = await s.generate(prompt="draft@1", text=str(input))
return await s.generate(prompt=self.critique_prompt, text=str(input), draft=str(draft))
@hw.runner argument |
Meaning |
|---|---|
name |
The runner's name, in the plugin's namespace |
reaches |
The hook points its executions fire, beyond the ones the harness fires around it. Declare it honestly (RULES R4): loading fails when the baseline or the budget needs a hook the runner can't reach |
config |
The Pydantic model of its settings |
contract |
The contract range it supports |
resumable |
Whether it can continue from a checkpoint after an approval (with resume holding the saved state) |
sample |
Settings for the conformance suite |
execute(ctx, input, resume) returns the task's output, and the harness fires output.before on it unless the run just accepted exactly that value, so a runner can't skip an output guard. There is nobody left to repair it at that point, so a Retry ends the run as validation.on_failure says; a runner that can repair checks the value itself first. Session.for_run(ctx) gives a session view of the run, so a runner can use generate, tool, decide, context and prompt like app code. The conformance suite runs every runner to a final state and checks it fires only hooks it declares (output.before may always fire). Runners don't plan or remember; open-ended agent behavior belongs in adapters for agent frameworks (R12).
Deciders¶
A decider is a routing stage: cascade: [{ use: acme.vip_rules }]. The simplest form is an async function:
@hw.decider("vip_rules") # deterministic by default
async def vip_rules(request: hw.DecisionRequest) -> hw.Decision:
"""VIP customers go straight to a person."""
if request.state.get("tier") == "vip":
return hw.Decision.exact("human", reason="vip customer")
return hw.Decision.abstain()
@hw.decider argument |
Meaning |
|---|---|
name |
The stage's name, in the plugin's namespace |
deterministic |
Default True; set False for a model or classifier, which then may only route, never grant (R3) |
contract |
The contract range it supports |
A DecisionRequest has router, text, options and state. Answer with Decision.exact(choice, reason=...), Decision.probabilistic(choice, confidence, reason=...) or Decision.abstain(). A decider with settings is a Decider provider class (@hw.provider(port="Decider", ...)) whose instances are called with the request. See Routing.
Adapters¶
An adapter governs a client or framework the app already uses: harness.instrument(client) and harness.wrap(agent) find one by the target's type and return a governed stand-in.
import harnessweaver as hw
from harnessweaver.adapters import Binding, CallProvider
@hw.adapter("chat", wraps=["acme_llm.Client"], reaches=["run.start", "input.before", "model.before",
"model.after", "output.before", "run.end"])
class ChatAdapter:
"""Governs acme_llm.Client.complete()."""
def bind(self, target: "acme_llm.Client", binding: Binding) -> "GovernedClient":
return GovernedClient(target, binding)
class GovernedClient:
def __init__(self, client, binding: Binding) -> None:
self._client, self._binding = client, binding
async def complete(self, prompt: str) -> str:
async def call(request: hw.ModelRequest) -> hw.ModelResponse:
text = await self._client.complete(request.messages[-1].content) # the redacted prompt
return hw.ModelResponse(text=text)
request = hw.ModelRequest(messages=(hw.Message(role="user", content=prompt),))
response = await self._binding.generate(request, CallProvider(call))
return response.text # as the hooks left it
@hw.adapter argument |
Meaning |
|---|---|
name |
The adapter's name, in the plugin's namespace |
wraps |
Fully qualified type names it can wrap ("openai.AsyncOpenAI"). instrument() loads the plugin whose namespace is the target's top-level package and picks the adapter that wraps its type; adapter= names one explicitly |
reaches |
The hook points one call through it fires; checked against the baseline and budget when the app wraps (R4) |
contract |
The contract range it supports |
sample |
A function returning an AdapterSample (an offline target and one call through it) for the conformance suite |
bind(target, binding) returns the stand-in. The Binding carries the harness, task, principal, tenant and attributes the app gave instrument() or wrap(), and binding.generate(request, provider) makes one governed call:
- outside a run, it becomes a small run of its own:
run.start,input.beforeon the message texts,model.before, the call,model.after,output.beforeon the answer,run.end; - inside a run or session (found through
hw.current_run()), it is one governed model call of that run.
CallProvider(call) wraps the adapter's own call to the client; call receives the request after model.before, so redactions and alias changes reach the real API. Translate the client's errors into the taxonomy (ProviderError), and keep the framework's types out of every hook payload (R7).
Agent frameworks¶
For an agent framework, one adapter object sends each of the framework's model requests through RunContext.generate(request, provider=...) and each tool call through RunContext.call_tool(call, backend=...), so budgets, cost, telemetry, recording and the governor apply as they do to the harness's own runners:
RunContext method |
Use |
|---|---|
generate(request, *, provider=None) |
One governed model call through the framework's own model |
call_tool(call, *, backend=None) |
One governed tool call through the framework's own tool |
approval_needed(call, reason, *, tool=None, approver_role=None) |
Turns an approval the framework asks for into the harness's own: returns the error that pauses the run (or escalates where it can't), matched to the call by name and arguments |
settle_grants() |
For a runner that runs interrupted work again: says the work so far won't run again, so the approvals it spent are gone for good. A resumable runner needn't call it |
replaying |
True during a replay. generate and call_tool then answer from the recording by themselves, ignoring provider and backend, so a replay never reaches the framework's own model or tools |
harnessweaver-pydantic-ai is the reference: its HarnessCapability does this for Pydantic AI, and its runner suspends and resumes agents with Pydantic AI's deferred tools. Read its source before writing another.
The conformance suite checks that one call through an adapter fires every declared hook (except conditional ones such as run.error) in lifecycle order.