Skip to content

Add GitHub Copilot CLI as an agent backend (--agent copilot) - #51

Open
PeterStefanec-prog wants to merge 4 commits into
AMAP-ML:mainfrom
PeterStefanec-prog:feat/copilot-cli-backend
Open

PeterStefanec-prog wants to merge 4 commits into
AMAP-ML:mainfrom
PeterStefanec-prog:feat/copilot-cli-backend

Conversation

@PeterStefanec-prog

@PeterStefanec-prog PeterStefanec-prog commented Aug 18, 2026

Copy link
Copy Markdown

Why

The harness is deliberately backend-agnostic ("Any model. Any agent backend."), but _AGENT_CHOICES currently stops at claude_code, codex, deepseek_harness and opencode. GitHub Copilot CLI is the one agent runtime a large group of users can actually run: many organizations buy Copilot Business/Enterprise seats and do not procure Claude Code or Codex. That single seat exposes both Claude and GPT models, so a Copilot backend widens who can run LongHorizon-Harness without changing anything about how the harness works.

Related: #30 asks for Gemini CLI. Both requests touch the same registration surface, so if you prefer a different shape for a new backend, saying so here settles both.

What this PR does

Adds CopilotAdapter on top of Copilot CLI's programmatic mode, registered exactly like OpenCodeAdapter.

Command shape — the prompt is piped from the harness's per-episode prompt file, so it never reaches argv:

copilot -s --no-ask-user <permission flags> --model <model> < <prompt file>
  • -s suppresses stats and decoration, leaving only the agent's answer on stdout.
  • --no-ask-user stops a headless round from stalling on a clarifying question.
  • COPILOT_GITHUB_TOKEN carries the harness credential when one is configured (highest precedence of the three tokens the CLI accepts).

Role separation uses Copilot's own permission flags. Deny rules outrank allow rules in Copilot CLI, so the auditor keeps read and shell access while losing the write tool — the same separation claude_permissions.py expresses through its deny list, and, as there, role separation rather than a sandbox:

Role Flags
manager, final_response --allow-tool=read
gui_executor, cli_executor --allow-all-tools --allow-all-paths --allow-all-urls
gui_auditor, cli_auditor, auditor_format_repair the executor flags plus --deny-tool=write

Registration: CLI choices and adapter factory, project config, supervisor role validation, Web API snapshot whitelist, model catalog and /api/meta, LH_HARNESS_COPILOT_BINARY resolution following the existing --version probe policy, doctor output, and both READMEs.

Trade-offs I want your call on

  1. No structured output. Copilot CLI has no --format json equivalent, so the episode log is prose, and prose is where this backend differs from every other one. Two consequences are handled in the adapter rather than left to the shared paths, since those paths reasonably assume stdout is an event stream: visible_output only delegates to the shared JSON parsers when the whole stream is JSON (so a future structured mode is still picked up for free, but an answer that quotes a record keeps the answer), and the stdout-derived runtime signals are cleared, because a reply quoting an error record would otherwise raise a hard failure on an exit-0 round. Real failures still arrive on stderr with a non-zero exit code and classify normally. trajectory_format: "text" is recorded for provenance — note nothing consumes that key today, so the Dashboard step list for a Copilot round is empty via parse_trajectory returning no steps, not via that flag. Plan, execution result, audit verdict and rework reason are unaffected. If you would rather have per-step evidence, --share=PATH exports a full markdown transcript per episode — happy to add that, but I did not want to invent a location for a new run artifact without your input.
  2. Default model. DEFAULT_COPILOT_MODEL is auto. Per-model access depends on the account's subscription and its organization's Copilot policy, and the CLI has no discovery endpoint, so any pinned id fails on first use for somebody. auto lets Copilot pick a model the account is entitled to; an explicit --model <id> still reaches the CLI unchanged and is rejected by Copilot itself when the account cannot use it.
  3. Base URL. Copilot CLI always talks to GitHub's endpoint, so base_url raises instead of being silently ignored. Say the word if you would rather it be accepted and dropped.
  4. MCP / GUI. copilot stays out of _MCP_AGENT_CHOICES, so this is CLI-only in phase 1, matching how deepseek_harness landed. Copilot CLI does support MCP servers, so the computer-use path can follow in a second PR once the config-passing flag is agreed.

Testing

I left the version bump and the changelog entry in the README out of this PR, since those follow your release cadence.

Copilot CLI is the only agent runtime available to many users whose
organizations buy Copilot seats but not Claude Code or Codex, and it can
drive both Claude and GPT models behind that one seat.  This adds it as a
fourth CLI backend, wired through the same AgentAdapter surface the other
backends use.

The adapter runs Copilot's programmatic mode: the prompt is piped from the
harness's per-episode prompt file so it never reaches argv, `-s` leaves only
the agent's answer on stdout, and `--no-ask-user` keeps a headless round from
stalling on a clarifying question.  Role separation is expressed with
Copilot's own permission flags, and because deny rules outrank allow rules the
auditor keeps read and shell access while losing `write`:

  manager / final_response -> --allow-tool=read
  gui_executor / cli_executor -> --allow-all-tools --allow-all-paths --allow-all-urls
  auditors -> the executor flags plus --deny-tool=write

Copilot CLI has no structured output mode, so the episode log is prose rather
than events: `visible_output` tries the shared JSON parsers first and falls
back to ANSI-stripped text, and the episode metadata reports
`trajectory_format: text` so the Dashboard's empty step list is not mistaken
for a failed round.  A custom base URL is rejected rather than silently
ignored, since Copilot CLI always talks to GitHub's endpoint.

Registration follows the OpenCode precedent: CLI choices and adapter factory,
project config, supervisor role validation, Web API snapshot whitelist, model
catalog and `/api/meta`, `LH_HARNESS_COPILOT_BINARY` resolution with the same
--version probe policy, and doctor output.

MCP is not wired up yet, so `copilot` stays out of _MCP_AGENT_CHOICES and the
GUI computer-use path; it can follow once the config-passing shape is agreed.
@Upper9527

Copy link
Copy Markdown
Collaborator

Thanks for the thorough Copilot CLI integration. I tested the current head (6280c08) on top of the latest main after #52, #33, and #28 were merged:

  • Full suite: 247 passed, 1 skipped.
  • The 12 focused Copilot adapter tests pass.
  • I also installed the official GitHub Copilot CLI in an isolated temporary directory and exercised the adapter against the real binary (GitHub Copilot CLI 1.0.80). The command shape, piped prompt, permission flags, output capture, and exit-code propagation all work.

There is one default-path problem to address before merge. With the PR's default model, the real CLI exits with:

Error: Model "claude-sonnet-4.6" from --model flag is not available.

Using the same adapter and environment with model="auto" succeeds and returns the expected SMOKE_OK response. Model availability is account- and organization-policy-dependent, so a fixed model can make the backend fail out of the box even though the integration itself is healthy.

Please change DEFAULT_COPILOT_MODEL to auto, omit --model when no explicit model was selected, or implement a clear unavailable-model fallback. Please update the model catalog/docs/tests consistently. Once that default behavior is addressed, this looks ready for another review.

Which models a Copilot seat can reach depends on the account's subscription
and its organization's policy, and the CLI exposes no discovery endpoint, so
any pinned id is unavailable to somebody.  Review of the backend hit exactly
that with the previous default:

  Error: Model "claude-sonnet-4.6" from --model flag is not available.

DEFAULT_COPILOT_MODEL is now "auto", which lets Copilot pick a model the
account is entitled to.  An explicit --model <id> still reaches the CLI
unchanged and is rejected by Copilot itself when the account cannot use it.

The catalog label was a hard-coded "Claude Sonnet 4.6 · default", which would
have kept rendering beside the id "auto" and offered the Web workbench one
model family while sending another.  It is derived from the constant now, the
way the Codex entry already is, so it cannot go stale again.  The discovery
warning names the new default and describes the failure an operator actually
sees when an explicit id is not open to their account.

Every existing model assertion compares against DEFAULT_COPILOT_MODEL, so
changing the constant alone left the suite green and proved nothing.  Two
tests pin the literal instead: a default-constructed adapter must emit
--model auto, and the catalog entry's id and label are pinned together in the
shape tests/test_model_catalog.py already uses for DeepSeek.
Two places the original registration missed, both user-facing.

doctor's no-runtime failure told the operator to install one of the other
four backends, even though the probe loop directly above it already tries
`copilot`.  Someone whose organization only buys Copilot seats was being
told the harness needs something else.

The workbench had no `copilot` entry in MODEL_PRESETS or in the hard-coded
agent list used when /api/meta is unavailable, and -- the one that shows in
normal operation -- no backend scope note, so Copilot was the only CLI-only
backend that did not tell the user computer-use and MCP are out of scope for
it.  deepseek_harness and opencode both carry that note.
Copilot CLI is the only backend whose stdout is the agent's answer rather
than a structured log, and two shared paths reasonably assume the opposite.
Both misread an ordinary reply that quotes JSON, which a coding agent does
routinely.

visible_output handed every episode to the shared JSON parsers first.
detect_format switches on a single JSON-looking line, so one quoted record
stood in for the whole answer:

  "Status: complete ... {"role": "assistant", "content": "ok"} ...
   Conclusion: no rework needed."  ->  "ok"

For an auditor that discards the verdict; for the manager, the plan.  The
parsers are now used only when every non-blank line is JSON, so a future
structured mode is still picked up for free while prose stays prose.

detect_runtime_signals reads the same stdout, so a reply quoting an error
record raised a hard response.failed signal on an exit-0, status=done
episode, which auditor_agent turns into a runtime failure report.  Those
signals are cleared for this backend: a genuine Copilot failure arrives on
stderr with a non-zero exit code, still classifies as before, and is
unaffected.

Both are pinned by tests that fail against the previous behaviour.  Also
refreshes the backend lists in the two READMEs and the role-runtime drawer
subtitle, which named a fixed subset of backends and had already gone stale.
@PeterStefanec-prog

Copy link
Copy Markdown
Author

Thanks for testing against the real CLI — that is the failure I could not have
caught here, since I have no Copilot seat.

Took your first option: DEFAULT_COPILOT_MODEL is now "auto", with the catalog,
docs and tests updated with it. Three follow-up commits, so the delta is readable.

The catalog label was a hard-coded "Claude Sonnet 4.6 · default", so flipping the
constant alone would have made /api/meta serve that label beside the id "auto".
It is derived from the constant now, matching the Codex entry.

On tests: the default was untestable as written — every model assertion compared
against DEFAULT_COPILOT_MODEL, so changing the constant left the suite green and
proved nothing. The new tests pin the literal, and I checked they fail against the
old default rather than assuming it.

Verifying that turned up two real bugs in my original adapter, both from the same
root cause: Copilot is the only backend whose stdout is the agent's prose, and two
shared paths reasonably assume it is an event stream.

  1. visible_output ran the shared JSON parsers first, and detect_format switches on
    a single JSON-looking line. Any answer quoting a record lost everything else —
    an auditor report collapsed to "ok". Parsers are now used only when every
    non-blank line is JSON, so a future structured mode still works for free.
  2. detect_runtime_signals reads the same stdout, so a reply quoting an error record
    raised a hard response.failed on an exit-0 round, which auditor_agent turns into
    a runtime failure report. Those signals are cleared for this backend; real
    failures come on stderr with a non-zero exit and classify unchanged.

Both are pinned by tests that fail against the previous behaviour. I would rather
surface these than have them found in review.

One thing I did not change: "auto" does not fix the path the README documents.
CONFIG_TEMPLATE ships an active model = "gpt-5.6-sol" under [run], and
_resolve_role_model only treats a role-scoped --*-agent as a backend boundary, so
after lh-harness init a global --agent copilot still sends --model gpt-5.6-sol.
That is pre-existing and backend-agnostic (--agent claude_code behaves the same),
and changing the template would change the Codex default for everyone. The README
config row now says to change model whenever you change agent. Happy to take it
on separately.

Also finished the registration I had missed: doctor's no-runtime message, the
workbench scope note, and the backend lists in both READMEs. I left the News bullet
and version bump out, since those follow your release cadence.

Suite on current main: 251 passed, 1 skipped — your 247 plus the four tests here.
Still no real Copilot seat on my side, so the "auto" behaviour rests on your v1.0.80
verification.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants