Skip to content

Harness tuning

The harness runs the agent loop inside the sandbox: OpenHands agent-server, driven by a small entrypoint, agent-run. This page is the guide; every setting with its value and bounds is on the Harness reference.

The levers that matter are outside the harness. Money, time and model come from the tier, the team from the template, the tools from the data class, and the run’s length from the task text. The cheapest optimization is a bounded task: an open “find out why” once spent its whole $2.55 on 319 grep steps and pushed nothing.

Start here

The five levers

LeverSetsChanged in
🎚️ TierUSD cap, deadline, modelFactory config
👥 TemplateWhich roles run: solo, pair, trioFactory config
🔒 Data classProvider, egress, tools, prompt cachingThe task’s source
📝 Task textHow far the agent wandersThe issue
⚙️ HarnessIterations, condenser, timeouts, reasoning effortA harness release, not per run

How a run works

agent-run starts agent-server, clones the repository, hands it the task, then follows it until the conversation ends.

    sequenceDiagram
  autonumber
  participant AR as agent-run
  participant AS as agent-server
  participant LLM as agent-router
  participant GH as GitHub
  AR->>AS: start, wait until ready
  AR->>GH: clone the branch
  AR->>AS: start the conversation (task, rules, tools)
  loop at most 500 iterations
    AS->>LLM: model call (300 s timeout)
    LLM-->>AS: tool calls
    AS->>AS: run the tools, condense history past 80 events
  end
  AS-->>AR: finished | error | stuck
  AR->>GH: revoke the run's token
  Note over AR: exit 0 on finished, 1 otherwise
  

The tester’s run

A tester run writes tests under the repository’s test paths and commits them. The harness then runs them red on the base, signs the commit and pushes it, or answers once (not_testable) and pushes nothing. The implementer that follows cannot commit a change to those files, and is told when they still fail. The model itself never pushes. testFirst is off by default; an operator turns it on per class (runbook 21), and a class without it runs without a tester (the roles).

What the step log looks like

One line per action, never its output, redacted for tokens. It is what you read most.

agent-run step 3: terminal | List crossplane controller dir and kustomization | ls /workspace/repo/... | trace_id=706f…
agent-run model retry 1/2 in 10s: ServiceUnavailableError …
agent-run: conversation ended with execution_status=finished
agent-run summary: 21 steps, 0 model retries

Where settings come from

Each layer lives in its own repository and is pinned into the next. A run’s settings are fixed when it is created; only its token cap can be raised while it runs.

    flowchart TB
  subgraph decide["Config and claim"]
    direction LR
    F["Factory config<br/>tiers · templates · caps"]:::cfg --> T["Triage<br/>tier + template"]:::cfg
    API["Run API<br/>task agent:run"]:::cfg --> C
    T --> C["AgentRun claim<br/>role · model · budget"]:::claim
  end
  subgraph run["The run"]
    direction LR
    K["Composition<br/>pod · env · egress"]:::claim --> H["Harness image<br/>loop settings"]:::run
    H -- every call --> G["Gateway<br/>models · MCP · budgets"]:::gw
  end
  decide -->|"the claim composes"| run
  classDef cfg fill:#6366f1,stroke:#4f46e5,color:#fff
  classDef claim fill:#059669,stroke:#047857,color:#fff
  classDef run fill:#f59e0b,stroke:#d97706,color:#111
  classDef gw fill:#7c3aed,stroke:#6d28d9,color:#fff
  
LayerRepositoryA change reaches
Factory configai-native-refNew runs, once Flux applies the release
Run APItask agent:run flagsThat run only
Claim and compositioncrossplane-configurationRuns created after the package upgrade
Harness imageMakuRuns created after its digest reaches the composition
Gatewayai-native-refEvery call, running runs included

Trade-offs

Turn upIt costsWatch for
⬆️ Tier, standard → frontierCaps ×2.7, deadline 45 → 90 minA wandering run spends all of it
⬆️ runMinutes aloneWall timeA deadline kill is never resumed: add money too
⬆️ runUSD aloneMoneyKeep taskUSD ≥ 2 × runUSD so a resume fits
⬆️ maxReviewRoundsOne implementer and one reviewer run per roundOut of rounds, the task escalates
⬆️ trio over pairOne more runThe tester keeps the implementer’s tier
⬆️ reasoning_effortReasoning tokens, seconds per stepAbove high, GLM-5.3 hits the 300 s timeout
✅ Prompt caching (internal)Nothing: cache reads cost 5%Needs the marker carried end to end

Measuring a change

Change one lever, run the same kind of task, compare. A run’s id is the 8 characters after xplane-run-.

On the agent-run dashboard:

QuestionPanel
What did it cost?Cost (USD)
How much input was cached?Tokens in / out
How many steps, which tools?Steps, Distinct steps
How long, and where did the time go?Duration, Run traces
How slow was the model?Model latency p50 / p95
Why did it end?Run, Errors

What real runs look like

Example (the reference host): typical runs, all on GLM-5.3.

TaskStepsCostEnd
docs-links10$0.03✅ succeeded
docs36$0.17✅ PR opened
docs-links225$1.65✅ PR opened
dashboards319$2.65❌ out of budget, nothing pushed
dashboards, frontier444+$4.46+⏳ still running at 66 min
Step count, not tier, separates runs. Most finish in 10–40 steps for under $0.20. And with 95% of input read from cache, a dollar cap fits better than a token cap: the out-of-budget run above read 7.7 M tokens for $2.65.

Failure modes

Budget and time

You seeDo this
Out of budget, no commit, hundreds of grep/cat stepsThe task has no stopping rule: scope it
Out of budget with limit run-tokensA hand-started run or an unpriced model: raise --max-tokens, or add a meter.prices row
PodFailed near the deadlineRaise runMinutes together with runUSD
A 429, then the agent stopsA gateway budget, never retried by design: check maku_factory_run_revocations_total

Loops and errors

You seeDo this
maximum iterations limit (500)The run looped: debug it
execution_status=stuckThe same action or error repeated: fix the tool, host or test it trips on
LLMServiceUnavailableError after model retry 2/2The provider failed 3 times: /factory retry
Steps take minutes, calls fail at 300 sreasoning_effort is not reaching the model: check the request body

The tester’s contract

You seeDo this
contract_not_red, “the runner could not run”The toolchain, not the tester: fix the sandbox’s runner, then retry
contract_outside_testsThe tester wrote outside the repository’s test paths: the commit is dropped, read the room
not_testableThe tester answered once that the issue has no test to write: a human decides

Environment and rooms

You seeDo this
The same refused action for minutes, in a roomRead the bridge’s refusal; approve it, or fork with more egress
Pod Pending 15 min, then run_unschedulableNo gVisor node yet: wait 1.5–3.5 min, or turn the warm headroom on
An internal run costs as much as a public onePrompt caching is off: the harness logs prompt cache NOT applied at start, and AgentCacheReadZero fires once the run passes 100k input tokens

Recipes

A wandering run is hundreds of reads and no commit. The stuck detector misses it, because every command differs.

  1. Give the task a scope: which files, which component.
  2. Give it an exit: “if X is not found, say so and stop”.
  3. Only then try a bigger tier.

Debug a run that loops

    flowchart TB
  A["Run long<br/>or failed"]:::start --> B{"execution<br/>status?"}
  B -- stuck --> S["Fix the repeated<br/>tool or host"]:::fix
  B -- error --> E{"Error line?"}
  E -- "max iterations" --> L
  E -- unavailable --> P["Retry the task"]:::fix
  E -- timeout --> R["Check<br/>reasoning_effort"]:::fix
  B -- "revoked or<br/>deadline" --> L{"Step mix?"}
  L -- "reads, no commit" --> W["Scope the task"]:::fix
  L -- "commits, pushes" --> G["More runUSD<br/>+ runMinutes"]:::fix
  L -- "refused in a room" --> Q["Approve, or fork<br/>with egress"]:::fix
  classDef start fill:#dc2626,stroke:#b91c1c,color:#fff
  classDef fix fill:#059669,stroke:#047857,color:#fff
  
  1. kubectl get agentrun -n agents xplane-run-<id>: phase and reason.
  2. The step log’s last 30 lines: repeating, or changing?
  3. The tool mix (the LogsQL tab above): 95% terminal and no commit is searching, not working.
  4. The room transcript for the model’s reasoning; the trace for timings.
  5. If it burns money, stop it: factory/stop on the issue.

Loop policy

The loop policy stops wandering runs and checks a finished run against the task’s acceptance checks. An impossible task escalates as “cannot be done as asked”, never as a false met, and a failing named check is never met. A class without a row behaves as before.

PieceWhat it does
Stall ruleAn implementer run with no commit by stallSteps steps, or two continuations in a row that add none, stops and posts what it found
Completion judgeAfter the run ends on its own, a fresh-context judge reads the acceptance checks and says met, not_yet or impossible
ContinuationA not_yet continues the same conversation, so the prompt cache survives, at most continuations times, each time-boxed to 15 minutes

The switch is the factory’s loops key, one row per class (default, review, or a class a repository declares). A row that sets any of its three keys switches that class on. No row, or a row without them, runs with no loop policy. Example (the reference host): docs and scripts set them; scripts has judge: tier-light, so the defaults apply (a stall at 80 steps, 2 continuations), and default: {scope: off} switches nothing on.

KeyDefault once a row existsBounds
stallSteps801 … 500
continuations20 … 5
judgetier-lightone of the four model aliases, or none for no judge

The factory copies the row into the claim as spec.loop, on every implementer claim of the task: the first run, revisions, CI fixes, retries and resumes. Reviewers, testers and triagers never carry it. The composition (crossplane-configuration v0.9.18) hands it to agent-run as AGENT_LOOP, a JSON object with stallSteps, continuations and judgeModel. A cluster whose composition is older refuses the claim, and the task escalates with loop_unsupported.

The three ends. met is not an end: the run finishes and the task goes to the next role.

EndRoomTaskIssueCounter
judge_impossibleharness_end event, then the judge’s text as a run noteEscalated, reason judge_impossibleFixed narration with the reason category only, no judge textmaku_factory_loop_runs_total{end="judge_impossible"}
stalledharness_end event, then the run’s findings as a noteEscalated, reason stalledThe sameend="stalled"
not_metharness_end event, then the last not_yet reason as a noteEscalated, reason not_metThe sameend="not_met"

The counter also reads finished for a run under the policy that ended normally, and other for any end not listed above, by class.

A pending check is waited for. A not_yet whose only unmet check is still running waits for the check inside the 15-minute box, and spends no continuation. When a check fails, the judge is shown the log’s last 60 lines, for up to 3 jobs. That quote is empty unless the harness’s egress allows the Actions log host (productionresultssa*.blob.core.windows.net); allowing it is a security decision for your platform.

Switching a class on is last: add its row only after you have tried it on one class. The meter and the factory’s outcomes then say whether cost per accepted pull request fell.

Search

Experimental. The implementer can ask a read-only helper one question about the repository instead of reading it file by file. Its benefit is unmeasured. Example (the reference host): only docs has search: true, so every other run behaves as before.

    %%{init: {"sequence": {"actorMargin": 20, "width": 120, "messageMargin": 25}}}%%
sequenceDiagram
  autonumber
  participant I as implementer<br/>(agent-server)
  participant S as search tool<br/>(same process)
  participant C as child<br/>conversation
  participant P as identity<br/>proxy /v1
  participant G as gateway<br/>(meter, caps)
  participant H as agent-run
  I->>S: search(question)
  S->>S: call cap from the ledger<br/>one at a time
  S->>C: fresh context:<br/>search brief + question<br/>grep, glob, read_file,<br/>list_directory
  loop at most 40 iterations, 300k tokens, 300 s
    C->>P: chat completion<br/>(the run's model,<br/>effort low)
    P->>G: the run's token<br/>x-ar-model pinned
    G-->>C: metered on the run,<br/>inside its caps
    C->>C: read-only tool calls<br/>in /workspace/repo
  end
  C-->>S: final message,<br/>or a stop with<br/>its partial answer
  S->>S: ledger row · header<br/>clip at 8,000 characters
  S-->>I: observation<br/>(an error observation<br/>on a failure)
  H->>H: reads the ledger:<br/>the child's steps<br/>join the stall count
  
CaseThe childThe implementer seesLedger outcome
Answeredfinishthe answercomplete
40 iterations, 300k tokens or 300 sstopped (the SDK’s limit or the watchdog’s pause)error observation: the bound and the partial answerbudget
A model error after the transient retriesstoppederror observation with the reasonerror
A budget 429stoppederror observation; the run then ends on its own next callerror
The 7th callnot started“no searches left: read the files yourself”refused
The stall rule fires mid-searchpaused by the executor when the parent is interrupted— the run ends stalledinterrupted
The pod stops mid-searchlost with the processon resume, the call’s error, as for any interrupted toolnone
agent_search fails to import—no search tool, a run as today— (search off logged)

The child sees nothing of the conversation, reads the uncommitted tree, never writes and never reaches the room. Its calls are metered on the run and bounded by the run’s USD cap, and the bridge classes the call plain. The switch is loops.<class>.search: true (reference), which does not switch the loop policy on.

Not tunable today

WantedWhat it takes
Per-task reasoning effort, iterations, condenserA harness change reading new env vars, set by the composition
A bigger sandbox for a factory runA factory change: it always writes size: small
A tier picked by task difficultyThe complexity classifier, not deployed: every task is standard
A different harnessA new profile in the composition

Related: Harness reference · Factory · Rooms · Observability · User guide