Add GitHub Copilot CLI as an agent backend (--agent copilot) - #51
PeterStefanec-prog wants to merge 4 commits into
Conversation
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.
|
Thanks for the thorough Copilot CLI integration. I tested the current head (
There is one default-path problem to address before merge. With the PR's default model, the real CLI exits with: Using the same adapter and environment with Please change |
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.
|
Thanks for testing against the real CLI — that is the failure I could not have Took your first option: DEFAULT_COPILOT_MODEL is now "auto", with the catalog, The catalog label was a hard-coded "Claude Sonnet 4.6 · default", so flipping the On tests: the default was untestable as written — every model assertion compared Verifying that turned up two real bugs in my original adapter, both from the same
Both are pinned by tests that fail against the previous behaviour. I would rather One thing I did not change: "auto" does not fix the path the README documents. Also finished the registration I had missed: doctor's no-runtime message, the Suite on current main: 251 passed, 1 skipped — your 247 plus the four tests here. |
e71bb4a to
9ae43b2
Compare
Why
The harness is deliberately backend-agnostic ("Any model. Any agent backend."), but
_AGENT_CHOICEScurrently stops atclaude_code,codex,deepseek_harnessandopencode. 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
CopilotAdapteron top of Copilot CLI's programmatic mode, registered exactly likeOpenCodeAdapter.Command shape — the prompt is piped from the harness's per-episode prompt file, so it never reaches
argv:-ssuppresses stats and decoration, leaving only the agent's answer on stdout.--no-ask-userstops a headless round from stalling on a clarifying question.COPILOT_GITHUB_TOKENcarries 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.pyexpresses through its deny list, and, as there, role separation rather than a sandbox:manager,final_response--allow-tool=readgui_executor,cli_executor--allow-all-tools --allow-all-paths --allow-all-urlsgui_auditor,cli_auditor,auditor_format_repair--deny-tool=writeRegistration: CLI choices and adapter factory, project config, supervisor role validation, Web API snapshot whitelist, model catalog and
/api/meta,LH_HARNESS_COPILOT_BINARYresolution following the existing--versionprobe policy,doctoroutput, and both READMEs.Trade-offs I want your call on
--format jsonequivalent, 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_outputonly 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 viaparse_trajectoryreturning 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=PATHexports 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.DEFAULT_COPILOT_MODELisauto. 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.autolets 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.base_urlraises instead of being silently ignored. Say the word if you would rather it be accepted and dropped.copilotstays out of_MCP_AGENT_CHOICES, so this is CLI-only in phase 1, matching howdeepseek_harnesslanded. 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
tests/test_copilot_adapter.py(15 new tests): binary resolution viaLH_HARNESS_COPILOT_BINARY, command construction and quoting, theautodefault reaching--model, the three permission policies, unknown-role and base-URL rejection, token plumbing, output parsing, an end-to-end episode against a fakecopilotexecutable, stderr/exit-code propagation on failure, and/api/metaexposure.tests/test_model_catalog.pygains a Copilot case pinning the catalog entry's id and label together, mirroring the DeepSeek one.main(with fix(eval/harbor): set self.extra_env so env-driven config works on Harbor >=0.13 #52, Recover from isolated EBADF in control-bus reads; keep the watcher alive #33, Guard-only failures fail the round, not the run; add validated guard_exclude_paths #28): 252 tests, no failures — 251 passed and 1 skipped on macOS, i.e. your 247 plus the four tests added here. The skip needs the prebuilt web bundle; the exact skip count varies with platform-conditional tests. The frontend change is covered bynpm run typecheck.lh-harness run --helplistscopilot;lh-harness doctorreports it like every other backend.I left the version bump and the changelog entry in the README out of this PR, since those follow your release cadence.