GitNexus/eval
Gergő Magyar e2da8d90ce
test(eval): run the benchmark offline against a scripted provider (#3235)
* feat(eval): a scriptable stand-in for Anthropic and OpenAI

Every defect this harness shipped last round was invisible to its own tests for
one reason: the tests exercised a layer BELOW where the code runs. The usage log
was never written because the proxy is a subprocess with a constructed
environment. The callback could not be imported because LiteLLM loads it by
path, not as a package. Failures went unrecorded because only the async hook was
overridden. CI or review caught all three; no unit test could, because each
called the function directly instead of driving the path that calls it.

This closes that gap without spending money. It speaks the two wire protocols
the harness actually depends on - Anthropic Messages, streaming and not, and
OpenAI Responses - so a run can go through the real sandbox, the real CLI, the
real gateway and the real usage callback with only the model faked. The runner
already supports pointing at it: --base-url is the same path the free-model
proxy documentation uses.

Scripted rather than simulated. A test decides what the model says, which tools
it asks for, and exactly what usage it reports. That last part is what makes
provider-native accounting testable at all: real cache hits are not reproducible
on demand, but a declared cache_read of 44,000 is. One Reply served down both
protocols is also the cleanest demonstration that the same billed work is stated
as a sum on one side and as a whole on the other.

Tool blocks are the mechanism for artifact-producing cells. The CLI runs what it
is asked to run, so a scripted Write block makes it write that file inside the
sandbox for real - no model deciding anything.

The end-to-end test drives the real proxy against the mock and asserts the usage
log records the provider's own arithmetic through the Anthropic-shaped
translation. It SKIPS here, because litellm's console script is absent in this
environment, so it is unverified until CI runs it - the same footing the
bubblewrap canary started on, and that one found a real bug on its first CI run.

Not yet built: driving a whole sweep against this. That needs a scripted reply
sequence that carries a cell to a scored artifact, which is the next step and
the point of the exercise.

668 eval tests pass, 17 skipped; the two test_model_gateway.py failures are the
pre-existing environmental ones.

* test(eval): run a real session against the scripted provider

The mock only proves something once the harness runs against it. This adds the
stand-in CLI and the first integration tests that use it, so a session goes
through the real code with only the model faked.

tests/fixtures/fake_claude.py does what the CLI does at the two boundaries the
harness depends on: it calls ANTHROPIC_BASE_URL for a turn, EXECUTES the tool
blocks that come back, and prints the stream-json sequence the parent parses.
Everything between - the session runner, the event-stream parse, the usage
extraction, the artifact capture, the scorer - stays real.

Four tests, chosen for the layers that have actually broken here: the usage a
provider reported survives to the row, a scripted Write produces an artifact
parse_review_output accepts, the prompt the harness meant to send is what
arrived, and an upstream 529 lands as a failed session rather than a usable
measurement.

Writing the stand-in found two things worth keeping. The prompt arrives on
STDIN under "-p --input-format text"; scanning argv for a non-flag token picks
up a flag's value instead, and the prompt-fidelity test is what caught it. And
three of these tests had been holding a sandbox they never applied, since no
command_prefix is passed - that implied coverage which was not there, so the
sandbox is gone from them and stays only in the artifact test, which needs its
review directory.

What these do NOT cover, checked rather than assumed: making the stand-in write
in place instead of atomically still passes. On the host-unsafe backend there is
no read-only mount to refuse it, so the atomic-write requirement remains a
bubblewrap mount property that only the real-sandbox canary can prove. Dropping
cache_read from the recorded usage does fail, so that half is genuinely pinned.

672 eval tests pass, 17 skipped; the two test_model_gateway.py failures are the
environmental ones.

* fix(eval): the usage adapter read a shape the callback never receives

Running the gateway against the scripted provider proved the accounting merged
in #3220 does not work, and the same run showed why nothing had caught it.

LiteLLM does not hand a logger the upstream body. It normalises usage into its
own Chat-Completions-shaped object first, so an OpenAI Responses reply reaches
the callback as prompt_tokens / prompt_tokens_details.cached_tokens - never the
input_tokens / input_tokens_details the shipped adapter reads. Every field came
back unknown. The observed call_type is "anthropic_messages" as well, because
Claude Code calls the Anthropic-shaped endpoint, so canonical_provider returned
None and normalize_usage would have refused outright.

Both were assumptions about a boundary I had only read about. The unit tests
agreed with them because their fixture was written in the same wrong shape, so
producer and consumer were consistent and both wrong - the exact failure the
producer/consumer round trip exists to catch, one layer further out.

Adds a LITELLM_NORMALIZED adapter for the object that actually arrives. The
arithmetic is still OpenAI's - prompt_tokens is the whole, the details are
subsets - so ordinary input is recovered by subtraction. The Responses adapter
stays for a raw upstream body, which the mock still serves and tests directly.
An unrecognised provider is still refused rather than guessed.

The fixtures now carry the measured shape, and the end-to-end test asserts it
through a real proxy: 48k prompt tokens with 44k cached is read back as 3k
ordinary rather than as silence.

676 eval tests pass, 16 skipped, none failing.

* test(eval): run a whole sweep offline, with negative controls

The layers between a model turn and a promotion decision had never been
exercised together. Unit tests covered each alone, and the paid runs that would
have covered the composition kept dying, so the contracts BETWEEN them went
unverified - which is where this harness has repeatedly shipped bugs.

Drives runner.main() the way the workflow does. Real task selection, hidden
oracle capture, sandbox, CLI subprocess, artifact capture, scoring against the
oracle, aggregation, health guard and promotion gate. Only the model is
scripted.

Getting to green meant satisfying nine real contracts nothing had exercised end
to end, and each failure was the harness correctly refusing bad evidence:
--unsafe-no-bwrap is restricted to the paired review arms; ce_* needs a plugin
carrying ce-plan, ce-work and ce-code-review; candidate_* needs an overlay; the
clone needs .gitnexus/meta.json with indexedAt and lastCommit; the evidence gate
needs a Skill request with a non-error result; review findings need exactly ten
fields with severity in critical/high/medium/low; and the hidden labels use a
DIFFERENT schema from the review output - line_start/line_end, six fields. That
last one only a real run surfaces.

Three negative controls, because a scorer that cannot be wrong measures nothing.
A finding in the wrong place is tp=0 fp=1 fn=1 and oracle-failed, while its
evidence stays VALID - being wrong is a quality result, not a broken
measurement. Approving defective code is a miss with no false positive, and
precision is None rather than 0, because it is undefined with no predictions.
One run cannot promote: the gate says it needs three valid paired runs.

A fourth control exists because a mutation demanded it. Forcing
skill_was_invoked_events to return True left every other test here passing, so
nothing pinned the gate that separates measuring a SKILL from measuring a model.

Writing it turned up behaviour worth recording rather than assuming: a
skill-not-invoked row still carries its score AND still counts toward the arm
median, because aggregate() drops EXCLUDED_ERROR_KINDS and evidence_valid=False
and skill-not-invoked is neither. The health guard stops the sweep, so a
single-run sweep cannot promote on it, but a mixed run's median would include a
cell whose skill never ran. Pinned as-is so it cannot change silently in either
direction; changing it is a promotion-semantics decision, not a test fix.

Two provisioning steps are stubbed and neither is harness logic: the pinned
runtime mounts (no node_modules in a worktree) and the sanitized graph build
(needs the gitnexus CLI at a mounted path). Containment is host-unsafe here;
bubblewrap stays with the real-sandbox canary.

681 eval tests pass, 16 skipped, none failing. Runs in ~18s.

* fix(eval): an uninvoked skill must not move the arm's quality median

Found by the offline sweep: a skill-not-invoked row still carried its score
into the arm's quality median. aggregate()'s filter dropped
EXCLUDED_ERROR_KINDS and evidence_valid=False, and skill-not-invoked is
neither, so an arm could be credited for a review it never performed with the
skill under test - which is the one thing an arm exists to measure.

Excluded from the QUALITY metrics only. Cost and duration still count that row,
because the session really ran and really was billed, and the promotion gate
still sees it, because it has its own vocabulary for a candidate that never
loaded its skill.

Two wider fixes were tried and abandoned, both because the tests said so rather
than because I reasoned it out first. Reusing the health guard's evidence_failed
predicate also excluded transcript-missing rows, but
test_aggregate_excludes_session_error_rows_from_medians pins those as counting:
that session ran, only its transcript is unverifiable. Excluding the row from
`valid` outright turned a candidate whose skill never loaded from
keep_incumbent into insufficient_evidence - the safety property held either way,
but the decision vocabulary is promotion semantics and not mine to change on a
measurement fix.

Mutation-checked: putting the rows back into the quality median fails the new
test. Both directions asserted, since a filter that excludes everything would
also pass - a wrong-but-valid review still moves quality, because being wrong is
exactly what a quality median should reflect.

682 eval tests pass, 16 skipped.

* test(eval): run the offline sweep unstubbed in the job that can, and probe CLI identity

Items 5 and 6 turned out to be one change. The containment (ubuntu) job already
installs bubblewrap, the pinned Claude CLI, node_modules and a built GitNexus -
everything the sweep's two provisioning stubs stand in for. So the stubs are not
a property of the test, only of a machine that lacks those things.

GITNEXUS_REQUIRE_FULL_SWEEP=1 makes the sweep run with nothing stubbed: real
containment instead of --unsafe-no-bwrap, the real runtime mounts, the real
sanitized graph. Set in that job, following the GITNEXUS_REQUIRE_BWRAP_CANARY
pattern already there. The gate FAILS on a missing piece rather than degrading
to the stubbed path, which is the point - a green tick that silently tested less
is what the bubblewrap canary was written to prevent.

Verified both states here: default green, and gate-on fails on this machine
rather than skipping, since it cannot create user namespaces.

Item 7 is an experiment, not an answer. Per-cell attribution needs an
identifier that travels WITH the request, because one proxy serves the whole
sweep and anything read from its environment is identical for every call. What
the real CLI sends is not documented anywhere I can check, and guessing a wire
format is exactly how the last three accounting bugs happened. So the probe
drives the REAL pinned CLI against the mock and records the identity-bearing
headers and body keys that arrive. It asserts only that a request was made; the
recorded evidence is the deliverable, and the job log preserves it. Skips
without CLAUDE_CANARY_BIN.

Two guards caught this rather than review: the repo pins the containment job's
env and its exact test list, so both had to be updated deliberately - which is
the guard working, not friction.

682 eval tests pass, 17 skipped.

* test(eval): make the offline sweep cross-task, so a scheduler change is checkable

The sweep fixture had one task, and a single task cannot show the thing a
cross-task scheduler changes: waves are per-task, so ordering, packing and a
breaker spanning a task boundary are all invisible with one.

A second task with its defect in a DIFFERENT file, and its own hidden labels,
makes per-task routing observable. The scripted reply is now task-aware, which
matters for the same reason: replying with the first task's finding scores the
second task wrong.

The load-bearing assertion is that each task scored against ITS OWN oracle.
That is the dangerous failure mode of interleaving cells from different tasks -
a mis-routed context or artifact scores one task against another's labels, and
every row still looks green. Mutation-checked: pointing every cell at the first
task's oracle snapshot fails it.

This is the safety net the packed-scheduler wiring needs. Measured earlier
against the real sweep_packed_cells, that change is worth -27% on a cold sweep
and -37% weekly, with breaker fidelity holding at three injected failure
positions - but it restructures a 125-line loop across ~92 names that also
holds graph prefetch, reuse selection, oracle staging and the canary drop.
Landing that on top of a one-task fixture would have been unverifiable, which
is why this comes first and separately.

682 eval tests pass, 17 skipped.

* fix(eval): commit the stand-in CLI's executable bit

The file was created and chmod +x'd locally, but committed 100644 - so the
mode existed only in my working tree. Any fresh checkout, CI included, gets a
non-executable file and every cell dies with "required executable is not an
executable regular file".

Found by accident: checking out origin/main and back to compare a flaky test
restored the file from the index and stripped the bit, which turned 5 green
tests into 9 failures. Without that detour this would have failed on the first
CI run instead.

Same shape as the bugs this branch exists to catch - something that works only
because of local state, breaking where the code actually runs.

* fix(eval): apply code review findings

Seven local reviewers and an independent cross-model pass. The headline is that
a fix I added in this branch was worse than the gap it closed.

Reverted the aggregate() quality-median filter. Excluding skill-not-invoked
rows from the quality metrics left valid_runs and excluded_runs still counting
them, so the promotion gate saw N clean runs while the median came from fewer.
The dropped rows are systematically an arm's worst, so it biased toward
PROMOTING - reproduced: one real run at 0.9 plus two uninvoked rows at 0.0 gave
the gate 3 valid runs, zero exclusions and a 0.9 median, flipping keep_incumbent
to promote. Three verdict fields compounded it: they are all() reducers still
reading the wider set, so one uninvoked cell flipped a whole arm. Five
reviewers found the two halves independently.

Closing it honestly needs a scored-run count plus a paired-equality check in
the gate, which is promotion semantics rather than an aggregation fix. The gap
is now pinned by a test that states why the half-fix was reverted.

Stopped forging the absence of CI. The runner refuses --unsafe-no-bwrap when CI
is set because that mode runs sessions with bypassPermissions behind a boundary
its own docstring calls "not a security boundary"; the sweep test deleted CI to
get past it, so eval / locked pytest ran an uncontained agent sweep on the
runner holding the checkout and credentials. It skips under CI instead - the
containment job still runs it for real with GITNEXUS_REQUIRE_FULL_SWEEP=1.

The stand-in CLI was lying in three ways. It never set is_error, so a refused
write read as a completed one. It had no Skill branch at all, so honoring
is_error revealed the evidence gate had been satisfied by a tool the fixture
never ran - the gate was measuring the fixture, not a skill. And a reply with no
usage became four zero-valued fields plus a fabricated cost, which is exactly
the unknown-is-not-zero confusion the accounting it feeds exists to prevent. A
provider failure also crashed the subprocess with no terminal result event.

The identity probe never ran anywhere. test_mock_provider.py was in no job's
file list, and the only job setting CLAUDE_CANARY_BIN runs a fixed list. My
commit message claimed the next containment run would produce the answer; it
would not have. Now wired in, with the CI-shape test updated to pin it.

Also: the regex-miss fallback wrote a predictable name in shared /tmp through a
symlink-following stage, now scoped to the test's own directory; and the
canonical_provider docstring plus the callback comment still asserted a
call_type branch the code no longer has.

Deferred as design decisions rather than review fixes: the containment sweep
uses the stand-in CLI rather than the pinned real one, the full-sweep path
bypasses the gateway so native usage accounting is unexercised there,
_normalize_litellm duplicates the Responses algorithm, and OPENAI_RESPONSES is
now unreachable from canonical_provider.

682 eval tests pass, 17 skipped, ruff clean.

* fix(eval): carry scripted tools over the Responses protocol

Review round on #3235. Three real items; five more were already fixed in
a20f94e1c and are answered on their threads rather than re-fixed.

`_openai_response` emitted only an `output_text` item and never read
`reply.tools`, so a reply scripted with a Write or Skill crossed the
gateway with the tool silently dropped. Responses is the protocol the
gateway is configured for BECAUSE it carries tool use, so the mock was
wrong about the wire on the one path that matters most. Function-call
items now accompany the message. Mutation-checked: reverting the emit
fails the new test on "the scripted tool must cross the Responses path".

The artifact session now takes `command_prefix` and
`require_pid_namespace` from the sandbox the way `run_arm` does instead
of calling `run_claude` bare. On host-unsafe `command_prefix_for`
returns `[]` by construction, so this pins the wiring, not the
isolation - the comment says so rather than implying more.

CodeQL's three unused-variable reports on one line were one finding: a
call whose result is entirely discarded. Unpack nothing there.

683 passed, 17 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(eval): forward the usage the provider reported instead of zero-filling it

Review round on #3235; all three findings valid.

The stand-in CLI defaulted absent cache fields to 0. That fabricated a
complete measurement out of an incomplete reply, and the second-order
effect was worse than the first: `runner_sessions` requires all four
USAGE_FIELDS before it calls a session measured, so a stand-in that
always emitted four fields made that guard unfirable from any offline
test. It was always satisfied.

It now forwards exactly what arrived. `Reply`'s cache fields accept None
to script absence, since a consumer that cannot tell "omitted" from
"zero" is the bug this harness exists to catch. Mutation-checked:
restoring the zero-fill fails the new test.

Also corrected a comment claiming aggregate() excludes skill-not-invoked
rows from the quality median. It does not - that was the filter reverted
in a20f94e1c for inverting a promotion, and the comment survived the
revert describing the opposite of what the test pins.

Dropped an unused monkeypatch fixture arg.

684 passed, 17 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(eval): keep an omitted cache field omitted on the Responses wire

Review round on #3235. The main finding is a miss in my own previous
commit: that one taught the Anthropic path to forward absence instead of
zero-filling, but `_openai_response` still serialized both
`input_tokens_details` keys unconditionally. Collapsing None to 0 is
right for the arithmetic - an unreported field adds nothing to the total
- and wrong on the wire, because `_int_or_none` reads an absent key as
unknown and a present 0 as a measured zero. So a reply scripted with
`cache_read_input_tokens=None` was indistinguishable from a
provider-reported zero on exactly one of the two protocols.

Half-applying the invariant was arguably worse than not applying it: the
Anthropic test passing made the pair look covered.

Mutation-checked: restoring the unconditional keys fails the new test.

Also: the module docstring claimed the stand-in executes the tool blocks
that come back, without noting Bash is stubbed; and dropped an unused
tmp_path fixture arg.

685 passed, 17 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(eval): validate every usage field the stand-in forwards

Review round on #3235. The guard checked input_tokens and output_tokens
for type and sign, but the forwarding comprehension passed the cache
fields through unchecked whenever present. The parent's well_formed test
only asks whether the four keys are PRESENT, so a negative, boolean, or
non-integer cache value rode into a `success` result and was recorded as
a usable measurement.

Same shape as the previous two rounds: the required half of a pair was
handled and the optional half was not. A field good enough to report is
good enough to check.

Mutation-checked: dropping the added clause fails all three parametrized
cases (negative, boolean, string).

688 passed, 17 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(eval): say which stand-in tools execute and which are modelled

Review round on #3235. The previous commit's docstring fix said "Write
and Skill really run" while correcting the Bash claim. Only Write really
runs: Skill validates the request and returns a synthetic result.

Fourth round of the same shape - the reported half of a pair gets fixed
and the sibling keeps the overclaim. Both docstrings now name each of
the three branches and what it actually does.

688 passed, 17 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:34:00 +01:00
..
agents docs: agent development framework, GitHub templates, eval refactor (#479) 2026-03-25 06:48:41 +00:00
analysis feat(eval): evolve review skills against historical PRs 2026-09-04 05:32:31 +00:00
bridge feat(eval): evolve review skills against historical PRs 2026-09-04 05:32:31 +00:00
configs feat: configure prettier with pre-commit hook (#563) 2026-03-28 14:58:04 +00:00
environments feat(eval-server): added --host for user configured host IP instead of system hardcoded IP (127.0.0.1) (#1667) 2026-05-18 16:00:42 +01:00
prompts repowiki CLI command implemented 2026-02-17 02:25:07 +05:30
tests test(eval): run the benchmark offline against a scripted provider (#3235) 2026-09-09 12:34:00 +01:00
utils docs: agent development framework, GitHub templates, eval refactor (#479) 2026-03-25 06:48:41 +00:00
workflow_bench test(eval): run the benchmark offline against a scripted provider (#3235) 2026-09-09 12:34:00 +01:00
.env.example repowiki CLI command implemented 2026-02-17 02:25:07 +05:30
.gitignore feat(eval): record provider-native usage at the gateway instead of inferring it after translation (#3220) 2026-09-08 18:22:04 +01:00
__init__.py repowiki CLI command implemented 2026-02-17 02:25:07 +05:30
constants.py docs: agent development framework, GitHub templates, eval refactor (#479) 2026-03-25 06:48:41 +00:00
pyproject.toml fix(eval): repair native containment checks 2026-09-05 10:48:39 +00:00
README.md docs: restructure root README, fact-check all READMEs (#2360) 2026-07-03 08:46:59 +01:00
run_eval.py feat(eval): evolve review skills against historical PRs 2026-09-04 05:32:31 +00:00
tool_registry.py docs: agent development framework, GitHub templates, eval refactor (#479) 2026-03-25 06:48:41 +00:00
uv.lock fix(eval): repair native containment checks 2026-09-05 10:48:39 +00:00

GitNexus SWE-bench Evaluation Harness

Evaluate whether GitNexus code intelligence improves AI agent performance on real software engineering tasks. Runs SWE-bench instances across multiple models and compares baseline (no graph) vs GitNexus-enhanced configurations.

What This Tests

Hypothesis: Giving AI agents structural code intelligence (call graphs, execution flows, blast radius analysis) improves their ability to resolve real GitHub issues — measured by resolve rate, cost, and efficiency.

Evaluation modes:

Mode What the agent gets
baseline Standard bash tools (grep, find, cat, sed) — control group
native Baseline + explicit GitNexus tools via eval-server (~100ms)
native_augment Native tools + grep results automatically enriched with graph context (recommended)

Recommended: Use native_augment mode. It mirrors the Claude Code model — the agent gets both explicit GitNexus tools (fast bash commands) AND automatic enrichment of grep results with callers, callees, and execution flows. The agent decides when to use explicit tools vs rely on enriched search output.

Models supported (see configs/models/ for the current list):

  • Claude Haiku 4.5, Claude Sonnet 4, Claude Opus 4
  • MiniMax M1 2.5, MiniMax M2.5
  • GLM 4.7, GLM 5
  • DeepSeek
  • Any model supported by litellm (add a YAML config)

Prerequisites

  • Python 3.11+
  • Docker (for SWE-bench containers)
  • Node.js 22+ (for GitNexus)
  • API keys for your chosen models

Setup

cd eval

# Install dependencies
pip install -e .

# Set up API keys — copy the template and fill in your keys
cp .env.example .env
# Then edit .env and paste your key(s)

All models are routed through OpenRouter by default, so a single OPENROUTER_API_KEY is all you need. To use provider APIs directly (Anthropic, ZhipuAI, etc.), edit the model YAML in configs/models/ and set the corresponding key in .env.

# Pull SWE-bench Docker images (pulled on-demand, but you can pre-pull)
docker pull swebench/sweb.eval.x86_64.django_1776_django-16527:latest

Debug logging

Set GITNEXUS_EVAL_DEBUG=1 to include full Python tracebacks in run summaries and logs. By default, errors are sanitized to avoid leaking host paths or stack traces.

Quick Start

Debug a single instance

# Fastest way to verify everything works
python run_eval.py debug -m claude-haiku -i django__django-16527 --subset lite

Run a single configuration

# 5 instances, Claude Sonnet, native_augment mode (default)
python run_eval.py single -m claude-sonnet --subset lite --slice 0:5

# Baseline comparison (no GitNexus)
python run_eval.py single -m claude-sonnet --mode baseline --subset lite --slice 0:5

# Full Lite benchmark, 4 parallel workers
python run_eval.py single -m claude-sonnet --subset lite -w 4

Run the full matrix

# All models x all modes
python run_eval.py matrix --subset lite -w 4

# Key comparison: baseline vs native_augment
python run_eval.py matrix -m claude-sonnet -m claude-haiku --modes baseline --modes native_augment --subset lite --slice 0:50

Analyze results

# Summary table
python -m analysis.analyze_results results/

# Compare modes for a specific model
python -m analysis.analyze_results compare-modes results/ -m claude-sonnet

# GitNexus tool usage analysis
python -m analysis.analyze_results gitnexus-usage results/

# Export as CSV for further analysis
python -m analysis.analyze_results summary results/ --format csv > results.csv

# Run official SWE-bench test evaluation
python -m analysis.analyze_results summary results/ --swebench-eval

List available configurations

python run_eval.py list-configs

Architecture

eval/
  run_eval.py              # Main entry point (single, matrix, debug commands)
  agents/
    gitnexus_agent.py      # GitNexusAgent: extends DefaultAgent with augmentation + metrics
  environments/
    gitnexus_docker.py     # Docker env with GitNexus + eval-server + standalone tool scripts
  bridge/
    gitnexus_tools.sh      # Bash wrappers (legacy — now standalone scripts are installed directly)
    mcp_bridge.py          # Legacy MCP bridge (kept for reference)
  prompts/
    system_baseline.jinja          # System: persona + format rules
    instance_baseline.jinja        # Instance: task + workflow
    system_native.jinja            # System: + GitNexus tool reference
    instance_native.jinja          # Instance: + GitNexus debugging workflow
    system_native_augment.jinja    # System: + GitNexus tools + grep enrichment docs
    instance_native_augment.jinja  # Instance: + GitNexus workflow + risk assessment
  configs/
    models/                # Per-model YAML configs
    modes/                 # Per-mode YAML configs (baseline, native, native_augment)
  analysis/
    analyze_results.py     # Post-run comparative analysis
  results/                 # Output directory (gitignored)

How It Works

Template structure

mini-swe-agent requires two Jinja templates:

  • system_template → system message: persona, format rules, tool reference (static)
  • instance_template → first user message: task, workflow, rules, examples (contains {{task}})

Each mode has a system_{mode}.jinja + instance_{mode}.jinja pair. The agent loads both automatically based on the configured mode.

Per-instance flow

  1. Docker container starts with SWE-bench instance (repo at specific commit)
  2. GitNexus setup: Node.js + gitnexus installed, gitnexus analyze runs (or restores from cache)
  3. Eval-server starts: gitnexus eval-server daemon (persistent HTTP server, keeps LadybugDB warm)
  4. Standalone tool scripts installed in /usr/local/bin/ — works with subprocess.run (no .bashrc needed)
  5. Agent runs with the configured model + system prompt + GitNexus tools
  6. Agent's patch is extracted as a git diff
  7. Metrics collected: cost, tokens, tool calls, GitNexus usage, augmentation stats

Tool architecture

Agent → bash command → /usr/local/bin/gitnexus-query
  → curl http://127.0.0.1:4848/tool/query   (fast path: eval-server, ~100ms)
  → npx gitnexus query                       (fallback: cold CLI, ~5-10s)

Each tool script in /usr/local/bin/ is standalone — no sourcing, no env inheritance needed. This is critical because mini-swe-agent runs every command via subprocess.run in a fresh subshell.

Eval-server

The eval-server is a lightweight HTTP daemon that:

  • Keeps LadybugDB warm in memory (no cold start per tool call)
  • Returns LLM-friendly text (not raw JSON — saves tokens)
  • Includes next-step hints to guide tool chaining (query → context → impact → fix)
  • Auto-shuts down after idle timeout

CLI flags:

Flag Default Purpose
--port <port> 4848 Port to listen on
--host <host> 127.0.0.1 Bind address — use 0.0.0.0 for cross-container access
--idle-timeout <seconds> 0 (disabled) Auto-shutdown after N seconds of inactivity

READY signal:

When the server is ready, it writes to stdout:

# IPv4
GITNEXUS_EVAL_SERVER_READY:127.0.0.1:4848

# IPv6 (bracketed to avoid colon ambiguity)
GITNEXUS_EVAL_SERVER_READY:[::1]:4848

Parse the port as the last colon-segment (split(':').pop()) — not split(':')[1], which breaks for IPv6 and for non-loopback IPv4 hosts added in this release.

Custom port and host

run_eval.py does not expose --port or --host as CLI flags. Configure them in your mode YAML under the environment: key:

# configs/modes/native_augment.yaml (or whichever mode you're running)
environment:
  eval_server_port: 4849         # change if 4848 is already in use on the host
  eval_server_host: "0.0.0.0"   # bind all interfaces — needed for cross-container setups

Defaults are port: 4848 and host: 127.0.0.1 (loopback only). Use 0.0.0.0 only when the agent container needs to reach the eval-server from a separate network namespace. The health probe and tool scripts connect via the configured bind host (defaulting to 127.0.0.1), which is reachable for both loopback and all-interface binds.

"localhost" is also a valid eval_server_host value. The OS resolves it at bind time — typically 127.0.0.1 on dual-stack or IPv4-only systems, and ::1 on IPv6-only systems. The exact result depends on your /etc/hosts and gai.conf. The READY signal will reflect the actual bound address (e.g. GITNEXUS_EVAL_SERVER_READY:127.0.0.1:4848 or GITNEXUS_EVAL_SERVER_READY:[::1]:4848), not the literal string localhost. Use this when you want the server to bind to whichever loopback address the OS prefers rather than forcing IPv4.

Running eval-server directly in Docker / Docker Compose:

# Bind to all interfaces so sibling containers can reach it
gitnexus eval-server --host 0.0.0.0 --port 4848

# Then probe from a sibling container via its service hostname
curl http://eval-container:4848/health

If you need a non-default port (e.g. to avoid conflicts), pass --port <port> alongside --host. The READY signal will reflect both:

GITNEXUS_EVAL_SERVER_READY:0.0.0.0:5000

Parse the port as the last colon-segment (split(':').pop()) — safe for both IPv4 and bracketed IPv6 forms.

Index caching

SWE-bench repos repeat (Django has 200+ instances at different commits). The harness caches GitNexus indexes per (repo, commit) hash in ~/.gitnexus-eval-cache/ to avoid redundant re-indexing.

Grep augmentation (native_augment mode)

When the agent runs grep or rg, the observation is post-processed: the agent class calls gitnexus-augment on the search pattern and appends [GitNexus] annotations showing callers, callees, and execution flows for matched symbols. This mirrors the Claude Code / Cursor hook integration.

Adding Models

Create a YAML file in configs/models/:

# configs/models/my-model.yaml
model:
  model_name: "openrouter/provider/model-name"
  cost_tracking: "ignore_errors"  # if not in litellm's cost DB
  model_kwargs:
    max_tokens: 8192
    temperature: 0

The model name follows litellm conventions.

Metrics Collected

Metric Description
Patch Rate % of instances where agent produced a patch
Resolve Rate % of instances where patch passes tests (requires --swebench-eval)
Total Cost API cost across all instances
Avg Cost/Instance Cost efficiency
API Calls Number of LLM calls
GN Tool Calls How many GitNexus tools the agent used
Augment Hits How many grep/find results got enriched
Augment Hit Rate % of search commands that got useful enrichment