A self-improving loop for any LLM-agent harness. Zero dependencies, pure Python stdlib.
Your agent's behavior is shaped by a growing pile of harness machinery — system prompts, config files, memory, hooks, injected context, tool schemas. Here is the uncomfortable truth:
Every one of those mechanisms compiles into a single artifact: the HTTP request sent to the model endpoint. If you never look at that artifact, "my memory system works" and "my hook fires" are guesses, not facts.
harness-loop makes the compiled prompt observable, checks it against expectations you declare, and closes the loop: detect → notify → dispatch a repair → record → re-verify.
It works with any agent that talks to an LLM HTTP API (Anthropic Messages or OpenAI-compatible chat), any notification channel (a shell command), and any scheduler (two cron lines). No framework buy-in.
your agent ──► tap (logging reverse-proxy) ──► api.anthropic.com / any base URL
│
▼ captured request bodies
check (expectation rules over the compiled prompt)
│ new finding
├──► notify_cmd (Telegram / Slack / stdout / anything)
└──► dispatch_cmd (e.g. inject a repair task into your live agent)
│ agent fixes + appends to improvements ledger
▼
watch (ledger → notify within minutes) next check = automatic re-verify
git clone https://github.com/jingchaodev/harness-loop && cd harness-loop
python3 -m harnessloop selftest # verify the engine (no network needed)
# 1. start the tap and point your agent at it
python3 -m harnessloop tap & # 127.0.0.1:8082 -> https://api.anthropic.com
export ANTHROPIC_BASE_URL=http://127.0.0.1:8082 # or OPENAI_BASE_URL for OpenAI-compatible
# ...restart your agent. Its requests now flow through the tap, unchanged.
python3 -m harnessloop report # see what your agent ACTUALLY sendsThen declare expectations — copy examples/harness-loop.example.json to
./harness-loop.json, and describe (a) the mechanisms your harness injects (a
distinctive substring each) and (b) the rules for when each must be present or absent:
{"mechanisms": [
{"id": "memory_index", "pattern": "## My memory index", "scope": "config_block",
"gloss": "memory index reached the model"}],
"rules": [
{"id": "R1", "mechanism": "memory_index", "expect": "present",
"when": {"class": "main"}, "gloss": "memory must be compiled into every main request"}]}python3 -m harnessloop check # run the rules over the last 24h of capturesWire the cron lines from examples/cron.example and the loop runs itself.
| Stage | Who enforces it | Component |
|---|---|---|
| Sense — capture every compiled prompt | network topology (base-URL env) | tap |
| Detect — expectation rules over captures | cron + deterministic code | check |
| Notify — what broke, in plain language | notify_cmd (your channel) |
check |
| Dispatch — hand the repair to your agent | dispatch_cmd (your mechanism) |
check |
| Record — durable ledger of every fix | the repair contract | improvements.jsonl |
| Notify fix — within minutes, automatically | cron watcher on the ledger | watch |
| Re-verify — unfixed issues reappear | next hourly check |
check |
Design stance: sensing and verification are structural (topology/cron — cannot be forgotten); judgment (diagnosing, designing the fix) belongs to your agent; approval of anything durable/outward stays human. The agent's weakest layer — remembering — is sandwiched: work arrives structurally (dispatch) and gets accepted structurally (re-check).
- A notification says what specific problem was found (the rule's plain-language
gloss) and what was already improved — never bare violation counts. - Acknowledged true-positives go in
known_issues— reported once, then suppressed. - Green = silence.
- A broken sensor pages (
sensor.scan_error) instead of silently shrinking the sample.
- Locate: inspect the failing captures (
python3 -m harnessloop report). - Classify: rule-precision bug (fix the rule) vs real harness defect (fix the harness).
- Fix and verify.
- Append one line to the improvements ledger:
{"ts": "...", "area": "...", "found": "...", "improved": "...", "evidence": "how verified + commit"} - Report. The
watchcron delivers it to your operator within minutes — and if you skip step 4, the missing notification is itself a visible gap.
Naive full-text matching will lie to you. Every scope below exists because the naive version produced a false result in production on day one:
| Scope | Meaning | Why it exists |
|---|---|---|
config_block |
the LAST message carrying your config header | in long-lived resumed sessions, message[0] is a fossil — config frozen at session start; fresh config is re-injected at compaction boundaries |
turn_start |
the last user message carrying turn_marker (+ its follow-up block) |
mid-turn continuation requests end with tool_result messages that never carry per-turn injections — anchoring to "last message" false-fails every multi-tool turn |
tools |
the request's tool names | tool presence ≠ tool mentioned in conversation |
anywhere |
whole request | only for patterns that can't be quoted back at you |
Self-reference rule: the moment your conversation discusses your own markers (you
will — you're building this thing), short patterns match the discussion, not the mechanism.
Use long distinctive phrases (config_block_needle should be a full header line, not a
token), and unique contract strings for unshipped features.
- Relay every HTTP method. Our first tap lacked
HEAD; the client's startup HEAD probe got501, it concluded the API was down, and its plugin system silently never attached. The agent went deaf and nothing errored. If you write your own tap: every method, and log the response status per request. - Classify requests before judging them. The capture stream is heterogeneous: tiny
quota/title probes, context summarizers, subagents. Judging them by main-loop rules
produced 25 false violations in our first run (
request_classhandles this). - Fossil sessions are real findings. Long-lived resumed sessions genuinely run on stale config until the next compaction. Do not silence it — classify it as a known issue and fix the governance (periodic restarts).
- Replay drift needs a tolerance band. If a rule re-runs a scoring sensor against today's index to judge yesterday's request, boundary scores flip. Gate expectations at a stricter threshold than the live mechanism uses.
- Gate dispatch on confirmed notification. Never let the system act internally while the operator was not told (our notify exits nonzero on failure → retry, no dispatch).
- Escape everything you forward to rich-text channels, and advance send-markers only on confirmed delivery.
- The tap binds loopback only by default and forwards
Authorizationheaders verbatim — never expose it off-box (it warns if you try). - Captured bodies contain your conversations. They live in
data_dir, are rotated (max_bodies), and should be treated like transcripts. Nothing leaves the machine. - Auth headers are never logged.
dispatch-tmux.shverifies a live agent owns the target pane beforesend-keys— injecting into a bare shell would execute your directive.
- Not an eval framework (it checks the compiled prompt, not answer quality).
- Not observability-as-a-service (no server, no dashboard, no account — files + cron).
- Not a proxy for secrets management or content filtering.
harnessloop/
tap.py the logging reverse-proxy (stdlib http.server + http.client, SSE-safe)
model.py provider-agnostic request view + the scoping primitives
rules.py mechanisms × expectation rules engine
check.py scan captures, apply known-issue suppression, notify + dispatch
watch.py improvements-ledger watcher (fix notifications within minutes)
config.py JSON config + env, sensible defaults
examples/ config, Telegram/Slack/stdout notifiers, tmux dispatch, cron lines
MIT. Born from running three always-on Claude-Code agents through this exact loop; the gotchas above are our first day's incident log, verbatim.