Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ jobs:
echo "::error::the always-run anchor step recorded '$GATE_HEAD', not the observed head"
exit 1
fi
recipe-openhands:
name: recipe (openhands)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
# openhands-sdk requires >=3.12; the certifier itself is stdlib-only, so
# its behavioural e2e already runs on the whole gates matrix.
python-version: "3.12"
- name: Install recipe dependencies
run: python -m pip install --upgrade pip pyyaml pytest jsonschema openhands-sdk==1.37.1 openhands-tools==1.37.1
- name: OpenHands schema-drift alarm
run: python -B -m pytest -q -p no:cacheprovider scripts/test_openhands_sdk_drift.py scripts/test_openhands_recipe.py

action-dogfood:
name: action (dogfood on flagship example)
Expand Down
17 changes: 14 additions & 3 deletions docs/gap-reports/scoreboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ for exactly the machinery our signals miss.
Harnesses whose run state lives fundamentally off-repo (OpenHands and
SWE-agent trajectories, platform-hosted runs) are out of scope: there is no
on-disk run record for a repo-native inspector to read — which is its own
answer to the question this scoreboard asks.
answer to the question this scoreboard asks.†

> † **Correction (2026-07-25).** For OpenHands this is now only half true. The
> V1 SDK (`openhands-sdk` 1.37.1) persists `base_state.json` + an `events/`
> trajectory whenever `persistence_dir=` is set — enough for an external
> certifier to read a run's terminal signal, its iteration cap, and its full
> event log. It is still not a *repo-native* contract (the record lives outside
> the repo by default and carries no spec, plan, or ledger), so the row stays
> out of the scoreboard; but the gap is addressable from outside, which is what
> [`examples/openhands-certify/`](../../examples/openhands-certify/) does.

## The scoreboard

Expand Down Expand Up @@ -283,8 +292,10 @@ FCR/RP — is a missing **layer**, one every harness on this board could emit
at its finish line. The port is small: four `loop.emit` calls
(`open_contract`, `append_iteration`, `append_receipt`, `terminate`), worked
end-to-end for a real engine in
[`docs/integrations/langgraph.md`](../integrations/langgraph.md) and
[`docs/integrations/temporal.md`](../integrations/temporal.md).
[`docs/integrations/langgraph.md`](../integrations/langgraph.md),
[`docs/integrations/temporal.md`](../integrations/temporal.md), and — for a
harness with no seam to hook at all —
[`docs/integrations/openhands.md`](../integrations/openhands.md).

## Contribute a row

Expand Down
118 changes: 118 additions & 0 deletions docs/integrations/openhands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# OpenHands — certify the run after it ends

OpenHands owns the EXECUTE tier: the autonomous coding runtime that plans, edits,
runs commands, and decides for itself when it is done. What it cannot do is
independently check *its own* completion claim — the agent both does the work and
declares `FINISHED`. Loop Engineer adds the tier *above* it: a contract-and-proof
layer that turns "the conversation finished" into evidence-backed proof-of-done.
It never replaces OpenHands; it certifies what OpenHands ran.

## The pattern

Unlike LangGraph (`END` edge) or Temporal (certify activity), OpenHands has **no
seam to insert a certify node into** — the run ends when the agent sets its own
execution status. So the recipe is a **post-run certifier** over the record the SDK
already persisted:

```
<persistence_dir>/<conversation-id-hex>/base_state.json # execution_status, max_iterations, stats
<persistence_dir>/<conversation-id-hex>/events/event-00000-<uuid>.json ← the trajectory
events/event-00001-<uuid>.json
```

The conversation id segment is the UUID **hex** (32 chars, no hyphens); a
conversation only persists when you pass `persistence_dir=`. The certifier takes a
conversation dir directly, so that composition rule is documentation, not code.

```python
from loop import emit
from loop.integrations import EngineOutcome, to_terminal_state

state = json.loads((conv_dir / "base_state.json").read_text(encoding="utf-8"))
events = sorted((conv_dir / "events").glob("event-*.json"), key=event_index)

gate = holdout_gate.decide(visible, withheld) # visible green + withheld green?
ac = anticheat_scan.scan(diff_text=git_diff, trajectory=[str(p) for p in events])
terminal = to_terminal_state(
outcome=to_engine_outcome(state, events, artifacts=[...]),
gate_verdict=gate, anticheat=ac,
criteria_met={"1": gate["verdict"] == "Succeeded"},
)
emit.terminate(ws, state=terminal["state"], criteria_met=terminal["criteria_met"],
evidence=terminal["evidence"], false_completion=terminal["false_completion"],
reason=terminal["reason"], iteration_id=1)
```

The certifier **imports no `openhands` package** — the record is plain JSON, so it
runs on Python 3.10 even though the SDK requires 3.12, and the gate needs no LLM
key. The event log doubles as the trajectory fed to `anticheat_scan.scan`: the
"the runtime ran the tests but the agent read the answer key" case OpenHands
cannot catch about itself, because the answer key is not part of its contract.

## OpenHands signal → typed terminal state

| `base_state.json` signal | `EngineOutcome` field | Typed terminal state |
|---|---|---|
| `execution_status: "finished"`, gate green + anticheat clean | `reached_end=True` | `Succeeded` |
| `execution_status: "finished"`, visible green / withheld red | `reached_end=True` | `FailedUnverifiable` (`false_completion: true`) |
| `execution_status: "stuck"` (stuck detector) | `budget_exhausted=True` | `FailedBudget` |
| `execution_status: "error"` + `ConversationErrorEvent.code == "MaxIterationsReached"` | `budget_exhausted=True` | `FailedBudget` |
| `execution_status: "error"`, any other code | `external_error="<code>: <detail>"` | `FailedBlocked` |
| `execution_status: "paused"` (`conversation.pause()`) | `human_abort=True` | `AbortedByHuman` |
| `idle` / `running` / `waiting_for_confirmation` (read mid-flight or abandoned) | `reached_end=False` | `FailedUnverifiable` |
| trajectory touched an answer-key path (anticheat HIGH) | — | `FailedUnverifiable` |
| the diff edits a gate script (anticheat CRITICAL) | — | `FailedSafety` |

**The precedence trap.** A max-iteration stop arrives *as*
`execution_status == "error"` with a `ConversationErrorEvent` whose `code` is
`MaxIterationsReached`. Since `to_terminal_state` ranks blocked above budget,
setting **both** `external_error` and `budget_exhausted` reports `FailedBlocked`
and silently loses the budget signal. Inspect the error code first and set
**exactly one**. `code` is a free-form `str`, so anything unrecognized falls
through to `FailedBlocked` — which fails safe: an unclassified error can never
become `Succeeded`.

## Zero-install mode

The `loop.integrations` module is convenience, not a requirement — the whole
projection is the SAME ~15 lines the LangGraph and Temporal recipes paste
(the adapter is engine-neutral):

```python
def to_terminal(gate, anticheat, criteria_met, evidence,
*, human_abort=False, blocked=None, over_budget=False):
fc = gate.get("false_completion") is True
if anticheat.get("downgrade_to") == "FailedSafety": state = "FailedSafety"
elif human_abort: state = "AbortedByHuman"
elif blocked: state = "FailedBlocked"
elif over_budget: state = "FailedBudget"
elif any(v is None for v in criteria_met.values()): state = "FailedSpecGap"
elif (not gate or not anticheat or anticheat.get("downgrade_to")
or gate.get("verdict") != "Succeeded" or fc
or not any(criteria_met.values()) or not evidence): state = "FailedUnverifiable"
else: state = "Succeeded"
return {"schema": "loop-engineer/terminal@1", "state": state,
"criteria_met": {k: v is True for k, v in criteria_met.items()},
"evidence": list(evidence), "false_completion": fc}
```

## Gate it in CI

```yaml
- run: pip install loop-engineer
- run: python certify_run.py run/ --conversation "$CONV_DIR" --agent-workspace "$WS"
- run: loop doctor run/ # -> {"ok": true}: the contract is structurally honest
- run: loop metrics run/ # -> false_completion_rate + evidence-backed scorecard
```

`loop metrics` scores the run from its on-disk evidence — not the agent's narration.

Verified against `openhands-sdk` 1.37.1 (2026-07-25), MIT
([`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk),
"Copyright (c) 2026 OpenHands contributors" — PyPI carries no license metadata).
The persistence layout, the `ConversationExecutionStatus` members, and the
`MaxIterationsReached` literal are pinned live by
[`scripts/test_openhands_sdk_drift.py`](../../scripts/test_openhands_sdk_drift.py).

Full runnable example (six committed fixture conversations + the false-completion
demo): [`examples/openhands-certify/`](../../examples/openhands-certify/).
9 changes: 9 additions & 0 deletions docs/superpowers/specs/2026-06-30-st3-integration-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,15 @@ truth.

### 5.3 OpenHands run → FCR gate *(alternate)*

> **Superseded 2026-07-25 by the shipped recipe.** OpenHands restructured into
> "V1": the runtime moved to `OpenHands/software-agent-sdk`, and none of the
> `openhands.run()` / `result.iterations` / `AgentStuckError` surface sketched
> below exists. The shape below (post-run hook, trajectory as anticheat input,
> stuck/max-iteration → `FailedBudget`) survived the rewrite intact; the API did
> not. Author against [`docs/integrations/openhands.md`](../../integrations/openhands.md)
> and [`examples/openhands-certify/`](../../../examples/openhands-certify/), not
> against this snippet.

**Composes:** the EXECUTE tier (autonomous coding runtime). OpenHands writes, runs,
and tests code in a sandbox — incidental verification, but "done" is still the
agent stopping. Loop Engineer wraps the run's exit in the false-completion gate.
Expand Down
85 changes: 85 additions & 0 deletions examples/openhands-certify/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# OpenHands recipe — certify the run after it ends

A runnable post-run certifier for [OpenHands](https://github.com/OpenHands/software-agent-sdk).
OpenHands keeps its own runtime; Loop Engineer adds the contract/proof tier above
it — evidence-backed state the `loop` CLI can independently validate and score.

## Why post-run

LangGraph has an `END` edge and Temporal has a certify activity. OpenHands has
neither: a run ends when the agent itself sets `execution_status = FINISHED`.
The seam that needs **zero engine changes** is therefore the record the SDK
already wrote when you pass `persistence_dir=`:

```
<persistence_dir>/<conversation-id-hex>/base_state.json
<persistence_dir>/<conversation-id-hex>/events/event-00000-<uuid>.json
```

`certify_run.py` reads that with nothing but `json` — it imports no `openhands`
package, so it runs on Python 3.10 while the SDK requires 3.12, and it needs no
LLM key.

## What it shows

```bash
python certify_run.py demo-run/ \
--conversation fixtures/conversations/finished \
--agent-workspace fixtures/workspaces/green
loop doctor demo-run/ # -> {"ok": true, ...}
loop metrics demo-run/ # -> clean scorecard
```

The certifier runs the same **visible + withheld** split the loop optimized
against through the real `holdout_gate.decide`, sweeps the event log through
`anticheat_scan.scan`, projects the OpenHands terminal through
`to_terminal_state`, and records it via `loop.emit`. It writes two evidence
artifacts a scorecard can join: the verbatim gate verdict
(`holdout-verdict.json`) and a verify bundle (`verify-T1.json`).

On a real pass the terminal is `Succeeded` with evidence, and `loop metrics`
scores the run clean: `false_completion_rate 0.0`, `evidence_backed: true`, the
two FCR methods agree.

### The false-completion demo

```bash
python certify_run.py sabotaged-run/ \
--conversation fixtures/conversations/finished \
--agent-workspace fixtures/workspaces/stale
```

Same conversation record — OpenHands still reports `finished` — but the work
product passes the **visible** check (the file exists) and fails the **withheld**
one (the content is wrong). That is the measurable false-completion event: the
terminal becomes `FailedUnverifiable` with `false_completion: true`, **never**
`Succeeded`. The dishonest completion is recorded, not laundered.

## Fixtures

`fixtures/conversations/` holds six conversation dirs captured from
`openhands-sdk` 1.37.1 (trimmed: the agent/LLM block is reduced, the system-prompt
event dropped; every field the certifier reads is verbatim).

| Fixture | `execution_status` | Terminal (with a green workspace) |
|---|---|---|
| `finished` | `finished` | `Succeeded` — or `FailedUnverifiable` with the `stale` workspace |
| `max-iterations` | `error` + `MaxIterationsReached` | `FailedBudget` |
| `stuck` | `stuck` | `FailedBudget` |
| `blocked` | `error` + `LLMAuthenticationError` | `FailedBlocked` |
| `paused` | `paused` | `AbortedByHuman` |
| `running` | `running` | `FailedUnverifiable` |

Every non-happy row is certified against a **green** workspace on purpose: a
passing check never overrides the engine's own terminal signal.

Because the fixtures are committed, `scripts/test_openhands_recipe.py` is
deterministic and credential-free and runs in the default gates matrix.
`scripts/test_openhands_sdk_drift.py` pins those fixtures against the *installed*
SDK and is the live schema-drift alarm (its own CI job, python 3.12).

## The general pattern

The complement framing, the full signal table, the precedence trap, and the
copy-paste (zero-install) projection live in
[`docs/integrations/openhands.md`](../../docs/integrations/openhands.md).
Loading
Loading