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.
grep steps and pushed nothing.Start here
The five levers
| Lever | Sets | Changed in |
|---|---|---|
| 🎚️ Tier | USD cap, deadline, model | Factory config |
| 👥 Template | Which roles run: solo, pair, trio | Factory config |
| 🔒 Data class | Provider, egress, tools, prompt caching | The task’s source |
| 📝 Task text | How far the agent wanders | The issue |
| ⚙️ Harness | Iterations, condenser, timeouts, reasoning effort | A 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 retriesWhere 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
| Layer | Repository | A change reaches |
|---|---|---|
| Factory config | ai-native-ref | New runs, once Flux applies the release |
| Run API | task agent:run flags | That run only |
| Claim and composition | crossplane-configuration | Runs created after the package upgrade |
| Harness image | Maku | Runs created after its digest reaches the composition |
| Gateway | ai-native-ref | Every call, running runs included |
Trade-offs
| Turn up | It costs | Watch for |
|---|---|---|
⬆️ Tier, standard → frontier | Caps ×2.7, deadline 45 → 90 min | A wandering run spends all of it |
⬆️ runMinutes alone | Wall time | A deadline kill is never resumed: add money too |
⬆️ runUSD alone | Money | Keep taskUSD ≥ 2 × runUSD so a resume fits |
⬆️ maxReviewRounds | One implementer and one reviewer run per round | Out of rounds, the task escalates |
⬆️ trio over pair | One more run | The tester keeps the implementer’s tier |
⬆️ reasoning_effort | Reasoning tokens, seconds per step | Above 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:
| Question | Panel |
|---|---|
| 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.
| Task | Steps | Cost | End |
|---|---|---|---|
| docs-links | 10 | $0.03 | ✅ succeeded |
| docs | 36 | $0.17 | ✅ PR opened |
| docs-links | 225 | $1.65 | ✅ PR opened |
| dashboards | 319 | $2.65 | ❌ out of budget, nothing pushed |
| dashboards, frontier | 444+ | $4.46+ | ⏳ still running at 66 min |
Failure modes
Budget and time
| You see | Do this |
|---|---|
Out of budget, no commit, hundreds of grep/cat steps | The task has no stopping rule: scope it |
Out of budget with limit run-tokens | A hand-started run or an unpriced model: raise --max-tokens, or add a meter.prices row |
PodFailed near the deadline | Raise runMinutes together with runUSD |
A 429, then the agent stops | A gateway budget, never retried by design: check maku_factory_run_revocations_total |
Loops and errors
| You see | Do this |
|---|---|
maximum iterations limit (500) | The run looped: debug it |
execution_status=stuck | The same action or error repeated: fix the tool, host or test it trips on |
LLMServiceUnavailableError after model retry 2/2 | The provider failed 3 times: /factory retry |
| Steps take minutes, calls fail at 300 s | reasoning_effort is not reaching the model: check the request body |
The tester’s contract
| You see | Do this |
|---|---|
contract_not_red, “the runner could not run” | The toolchain, not the tester: fix the sandbox’s runner, then retry |
contract_outside_tests | The tester wrote outside the repository’s test paths: the commit is dropped, read the room |
not_testable | The tester answered once that the issue has no test to write: a human decides |
Environment and rooms
| You see | Do this |
|---|---|
| The same refused action for minutes, in a room | Read the bridge’s refusal; approve it, or fork with more egress |
Pod Pending 15 min, then run_unschedulable | No gVisor node yet: wait 1.5–3.5 min, or turn the warm headroom on |
| An internal run costs as much as a public one | Prompt 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.
- Give the task a scope: which files, which component.
- Give it an exit: “if X is not found, say so and stop”.
- 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
kubectl get agentrun -n agents xplane-run-<id>: phase and reason.- The step log’s last 30 lines: repeating, or changing?
- The tool mix (the LogsQL tab above): 95%
terminaland no commit is searching, not working. - The room transcript for the model’s reasoning; the trace for timings.
- If it burns money, stop it:
factory/stopon 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.
| Piece | What it does |
|---|---|
| Stall rule | An implementer run with no commit by stallSteps steps, or two continuations in a row that add none, stops and posts what it found |
| Completion judge | After the run ends on its own, a fresh-context judge reads the acceptance checks and says met, not_yet or impossible |
| Continuation | A 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.
| Key | Default once a row exists | Bounds |
|---|---|---|
stallSteps | 80 | 1 … 500 |
continuations | 2 | 0 … 5 |
judge | tier-light | one 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.
| End | Room | Task | Issue | Counter |
|---|---|---|---|---|
judge_impossible | harness_end event, then the judge’s text as a run note | Escalated, reason judge_impossible | Fixed narration with the reason category only, no judge text | maku_factory_loop_runs_total{end="judge_impossible"} |
stalled | harness_end event, then the run’s findings as a note | Escalated, reason stalled | The same | end="stalled" |
not_met | harness_end event, then the last not_yet reason as a note | Escalated, reason not_met | The same | end="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
| Case | The child | The implementer sees | Ledger outcome |
|---|---|---|---|
| Answered | finish | the answer | complete |
| 40 iterations, 300k tokens or 300 s | stopped (the SDK’s limit or the watchdog’s pause) | error observation: the bound and the partial answer | budget |
| A model error after the transient retries | stopped | error observation with the reason | error |
| A budget 429 | stopped | error observation; the run then ends on its own next call | error |
| The 7th call | not started | “no searches left: read the files yourself” | refused |
| The stall rule fires mid-search | paused by the executor when the parent is interrupted | — the run ends stalled | interrupted |
| The pod stops mid-search | lost with the process | on resume, the call’s error, as for any interrupted tool | none |
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
| Wanted | What it takes |
|---|---|
| Per-task reasoning effort, iterations, condenser | A harness change reading new env vars, set by the composition |
| A bigger sandbox for a factory run | A factory change: it always writes size: small |
| A tier picked by task difficulty | The complexity classifier, not deployed: every task is standard |
| A different harness | A new profile in the composition |
Related: Harness reference · Factory · Rooms · Observability · User guide