Packages

The Harness.AgentAdapter behaviour plus six headless coding-agent CLI adapters (claude, codex, cursor, grok, antigravity, pi), driven uniformly over OTP Ports.

Current section

Files

Jump to
Raw

README.md

# HarnessAgentAdapter

[![License](https://img.shields.io/github/license/ZenHive/harness_agent_adapter.svg)](https://github.com/ZenHive/harness_agent_adapter/blob/main/LICENSE)

The `Harness.AgentAdapter` behaviour plus six adapters for headless
coding-agent CLIs — Claude Code, Cursor, Codex, Grok, Antigravity, and Pi —
all driven over OTP Ports. No per-agent SDK, no output normalization: an
adapter builds the agent's headless command line, spawns it, classifies the
port's messages as raw output / termination / failure, and can kill an
in-flight run. It does not parse the agent's output.

## Why

Every one of these CLIs ships its own JSON-ish transcript shape and revises
it across releases. A normalization layer chases that churn forever and
still loses information the consumer wanted. This package's adapters pass
raw output straight through — the consumer is expected to be an AI that
reads a transcript natively, not a program pattern-matching on a schema.
What stays code is mechanical: spawn the process, capture its bytes, detect
that it stopped, kill it on a deadline. What agent output *means* is left to
whoever reads it.

`Harness.AgentAdapter.invoke/2` does the generic Port spawn shared by every
adapter; `Harness.AgentAdapter.Driver.run/3` drives a spawned run to
completion under total/idle/progress deadlines. Termination is derived from
the port closing or a deadline firing — **never** from the process exit
code, which every adapter here treats as advisory-only.

## Installation

Add it to your dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:harness_agent_adapter, "~> 0.1.0"}
  ]
end
```

Docs: [hexdocs.pm/harness_agent_adapter](https://hexdocs.pm/harness_agent_adapter).

## Usage

```elixir
alias Harness.AgentAdapter
alias Harness.AgentAdapter.Driver
alias Harness.AgentAdapter.Invocation

invocation = %Invocation{
  prompt: "Fix the failing test in lib/foo.ex",
  cwd: "/path/to/isolated/worktree",
  log_tag: "run-123",
  model: "gpt-5.6-sol",
  rule_content: "# Operational rules\n\nRun the project's checks before finishing.\n"
}

{:ok, outcome} = Driver.run(AgentAdapter.Codex, invocation)

outcome.kind        # :exited | {:timed_out, :idle | :total} | {:reflex_halted, reason} | {:error, reason}
outcome.output       # the agent's raw transcript, unparsed
outcome.exit_status   # advisory only — never branch success on this
```

`Driver.run/3` is the composed entry point most callers want (spawn +
drive-to-completion under deadlines). `AgentAdapter.invoke/2` is the lower
layer — it just spawns and returns a `Harness.AgentAdapter.Run` handle for a
caller that wants to drive the receive loop itself.

## Adapters

| Adapter | Headless CLI | Raw output format |
|---|---|---|
| `Harness.AgentAdapter.Claude` | `claude -p` | `--output-format stream-json` |
| `Harness.AgentAdapter.Cursor` | `cursor-agent -p` | `--output-format stream-json` |
| `Harness.AgentAdapter.Codex` | `codex exec` | `--json` |
| `Harness.AgentAdapter.Grok` | `grok -p` / `agent` subcommand | `--output-format streaming-json` |
| `Harness.AgentAdapter.Antigravity` | `agy -p` | plain text |
| `Harness.AgentAdapter.Pi` | `pi -p` | `--mode json` |

All six adapters declare `worktree_isolation: true` — their headless mode
edits only the port's `cwd`, never a directory outside it. Model and agent
are orthogonal: `Invocation.model` threads to each adapter's `--model` flag
independently of which adapter (`assignee`) you dispatch to, so e.g. Cursor
is a multi-model front end, not "the Composer agent."

## Configuration

```elixir
config :harness_agent_adapter, :run,
  total_timeout: 1_800_000,
  idle_timeout: 300_000,
  progress_timeout: 300_000,
  terminate_grace_ms: 1_000
```

All keys are optional; see `Harness.AgentAdapter.Driver`'s moduledoc
for the shipped defaults and what each deadline guards (total-run budget,
idle-output window, no-mechanical-progress window). `:terminate_grace_ms`
is the SIGTERM-to-SIGKILL window `OSProcess.kill_tree/1` waits so agent
CLIs can flush their transcript tail. Per-call `opts` passed to
`Driver.run/3` override the application config.

## Live integration tests

`mix test --include integration` drives each real agent CLI end to end. The
model is required, never defaulted, so pin one per adapter — any id the CLI
lists works:

| Env var | List models with |
|---|---|
| `HARNESS_AGENT_ADAPTER_LIVE_MODEL_CLAUDE` | `claude --help` (`--model`) |
| `HARNESS_AGENT_ADAPTER_LIVE_MODEL_CODEX` | `model` in `~/.codex/config.toml` |
| `HARNESS_AGENT_ADAPTER_LIVE_MODEL_CURSOR` | `cursor-agent --list-models` |
| `HARNESS_AGENT_ADAPTER_LIVE_MODEL_GROK` | `grok models` |
| `HARNESS_AGENT_ADAPTER_LIVE_MODEL_ANTIGRAVITY` | `agy models` |
| `HARNESS_AGENT_ADAPTER_LIVE_MODEL_PI` | `pi --list-models` |

A missing CLI or unset variable fails the test with the exact fix. Your own
adapter run through `Harness.AgentAdapter.Testing.ConformanceCase` reads
`HARNESS_AGENT_ADAPTER_LIVE_MODEL_<NAME>`, where `<NAME>` is the module's
last segment, upcased.

## Public API

The package's public surface is intentionally small — an adapter and its
callers should need nothing beyond these modules:

- `Harness.AgentAdapter` — the behaviour: required callbacks
  `capabilities/0`, `rule_channel/0`, `build_command/1`; `classify_message/2`
  and `terminate/1` default via `use Harness.AgentAdapter` and are
  overridable. Also hosts `invoke/2`, `attach_rules/2`, `supports?/2`,
  `model_supported?/2`, `model_args/1`, `permission_flag/2`,
  `check_permission_mode/2`, and `resume_args/1`.
- `Harness.AgentAdapter.Driver` — `run/3`, the spawn-and-drive-to-completion
  entry point, with `:on_spawn` / `:on_output` hooks.
- `Harness.AgentAdapter.Watchdog` — the deterministic mid-run guard: idle,
  total, and progress-stall deadlines, plus blocked-command detection
  (`git push --force`, `mix deps.clean`, `rm -rf` outside the worktree).
- `Harness.AgentAdapter.Invocation` — the run-request struct: prompt, cwd,
  session, permission mode, model, rule content, adapter opts, env.
- `Harness.AgentAdapter.Capabilities` — the static per-adapter declaration:
  session resume, permission modes, streaming output, worktree isolation,
  cost tier, auth-env scrub list, model families.
- `Harness.AgentAdapter.Outcome` — the completed-run result: raw `output`,
  advisory `exit_status`, and the authoritative `kind`.
- `Harness.AgentAdapter.Run` — the live-run handle (port, OS pid, adapter,
  composed input) returned by `invoke/2`.
- `Harness.AgentAdapter.Registry` — name-to-`{module, render agent}`
  resolution for the six adapters (`resolve/1`, `delegatable?/1`).
- `Harness.AgentAdapter.RuleDelivery` — the delivered-rules struct threaded
  through `Invocation.rules`.
- `Harness.AgentAdapter.Claude` / `.Cursor` / `.Codex` / `.Grok` /
  `.Antigravity` / `.Pi` — the six shipped adapters.
- `Harness.AgentAdapter.Testing.ConformanceCase` — the reusable ExUnit case
  every adapter is checked against. Ships in `lib/` (not `test/support/`) so
  a downstream consumer defining its own adapter can `use` it directly from
  their own test suite.
- `Harness.AgentAdapter.Testing.ProcessFixture` / `.GitFixture` — the
  throwaway OS process and throwaway git repository the conformance suite's
  generated tests call. They ship in `lib/` for the same reason the case does:
  a consumer's build compiles this package's `lib/` and nothing else.

Two seams make the package standalone rather than harness-coupled:

- **Caller-supplied rule content.** The package never renders or filters
  operational rules — it only chooses *how* they reach each agent
  (`c:Harness.AgentAdapter.rule_channel/0`: an ephemeral system-prompt file
  for Claude, ephemeral `AGENTS.md` / `.cursor/rules/` files for Codex and
  Cursor, a prompt preamble for Grok and Antigravity, or no channel at all).
  The caller renders `Invocation.rule_content` however it wants and hands it
  in; `Harness.AgentAdapter.attach_rules/2` and
  `Harness.AgentAdapter.RulesInjection` handle delivery.
- **The watchdog.** `Harness.AgentAdapter.Watchdog` owns every mid-run
  deadline and the blocked-command guard, independent of any particular
  orchestrator's run lifecycle — a caller driving `invoke/2` directly gets
  the same deterministic reflex halts that `Driver.run/3` uses internally.

## Primary Consumer

[`harness`](https://github.com/ZenHive/harness) is the OTP-native
orchestration engine this package was extracted from and the primary
consumer: it dispatches implementer and reviewer agents into isolated git
worktrees through these adapters as part of its implement → review → land
loop.