diff --git a/README.md b/README.md index 247c73c..2761904 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ implements the **orchestrator → worker → reviewer pattern** (the AI agent orchestration loop) as a deterministic harness with a closed feedback loop: a goal is decomposed into subtasks, fanned out to worker subagents, aggregated, and run through a review gate that loops until the work meets its success criteria. One -loop drives any LLM backend — Anthropic Claude, Claude Code, Codex, opencode, or -aider — through a single `Agent` interface, and it ships as both an **MCP server** +loop drives any LLM backend — Anthropic Claude, Claude Code, Codex, opencode, aider, +or GitHub Copilot — through a single `Agent` interface, and it ships as both an **MCP server** and a plain CLI so any coding agent can call it. The design principle: **the loop is a harness (deterministic code), not a skill.** @@ -97,7 +97,7 @@ from agentloop.adapters import CliAgent # Point the worker at a repo and let it actually edit files headlessly: agent = CliAgent.claude_code(cwd="/path/to/repo", skip_permissions=True) -orch = Orchestrator(agent) # or .codex() / .opencode() / .aider() / .grok_build() +orch = Orchestrator(agent) # or .codex() / .opencode() / .aider() / .grok_build() / .copilot() result = orch.run(goal="Add a /health endpoint + test", success_criteria="test passes") ``` @@ -156,7 +156,7 @@ python3 -m examples.run_with_cli_agent claude /path/to/repo ``` ```bash -python3 -m examples.run_with_cli_agent claude # codex | opencode | aider | grok +python3 -m examples.run_with_cli_agent claude # codex | opencode | aider | grok | copilot ``` Custom CLI? It's just a command template (`{prompt}`, `{system}`, `{combined}`; @@ -292,8 +292,8 @@ is unnecessary: `claude mcp add athena-loops -- agentloop-mcp`. Then (restart the session first) ask the host agent to "use agentloop to orchestrate: ". The default `backend="auto"` picks the matching worker for the caller (`codex` from Codex, `opencode` from OpenCode, `claude_code` from -Claude Code when detectable). Choose a concrete `backend` (`claude_code`, -`codex`, `mock`, …) to override that. +Claude Code, `copilot` from GitHub Copilot when detectable). Choose a concrete +`backend` (`claude_code`, `codex`, `copilot`, `mock`, …) to override that. For agents that *don't* speak MCP but can run a shell, there's a plain CLI over the same contract: diff --git a/agentloop/adapters/cli.py b/agentloop/adapters/cli.py index 27a87cb..d7af063 100644 --- a/agentloop/adapters/cli.py +++ b/agentloop/adapters/cli.py @@ -256,3 +256,17 @@ def grok_build( if model: cmd += ["-m", model] return cls(cmd, **kw) + + @classmethod + def copilot( + cls, *, model: Optional[str] = None, skip_permissions: bool = False, **kw + ) -> "CliAgent": + # `-s` prints only the final answer (clean stdout); `--no-ask-user` keeps + # the agent autonomous instead of stopping to ask. `--allow-all-tools` + # auto-approves tool use for headless runs (the skip-permissions analogue). + cmd = ["copilot", "-p", "{combined}", "-s", "--no-ask-user"] + if skip_permissions: + cmd.append("--allow-all-tools") + if model: + cmd += ["--model", model] + return cls(cmd, **kw) diff --git a/agentloop/cli.py b/agentloop/cli.py index 7a492c3..5c543b1 100644 --- a/agentloop/cli.py +++ b/agentloop/cli.py @@ -170,7 +170,7 @@ def build_parser() -> argparse.ArgumentParser: help="let CLI workers use tools without prompting (needs --cwd)") r.add_argument("--no-isolate", action="store_true", help="do NOT run in a throwaway worktree when --cwd is set") - r.add_argument("--model", help="model override for claude_code / grok_build / claude_api / grok_api") + r.add_argument("--model", help="model override for claude_code / grok_build / copilot / claude_api / grok_api") r.add_argument("--json", action="store_true", help="emit full result as JSON") r.add_argument("--progress", action="store_true", help="stream per-iteration NDJSON to stderr") diff --git a/agentloop/mcp_server.py b/agentloop/mcp_server.py index a2eb781..169c1e0 100644 --- a/agentloop/mcp_server.py +++ b/agentloop/mcp_server.py @@ -44,6 +44,7 @@ "opencode": CliAgent.opencode, "aider": CliAgent.aider, "grok_build": CliAgent.grok_build, + "copilot": CliAgent.copilot, } BACKENDS = ["mock", "claude_api", "grok_api", *_CLI_PRESETS] DEFAULT_BACKEND = "auto" @@ -96,6 +97,7 @@ def _backend_status(name: str) -> dict[str, Any]: "opencode": "opencode", "aider": "aider", "grok_build": "grok", + "copilot": "copilot", }[name] path = shutil.which(executable) status: dict[str, Any] = { @@ -130,6 +132,7 @@ def _caller_backend(caller_agent: Optional[str] = None) -> Optional[str]: "grok_api": "grok_api", "xai_api": "grok_api", "xai": "grok_api", + "copilot": "copilot", } if hint in aliases: return aliases[hint] @@ -143,6 +146,8 @@ def _caller_backend(caller_agent: Optional[str] = None) -> Optional[str]: ("CLAUDE_CODE", "claude_code"), ("CLAUDE_SESSION_ID", "claude_code"), ("GROK_AGENT", "grok_build"), + ("COPILOT_CLI", "copilot"), + ("COPILOT_AGENT_SESSION_ID", "copilot"), ) for env_name, backend in env_hints: if os.environ.get(env_name): @@ -246,7 +251,7 @@ def _build_agent(backend: str, cwd: Optional[str], skip_permissions: bool, if cwd: kw["cwd"] = cwd kw["skip_permissions"] = skip_permissions - if backend in ("claude_code", "grok_build") and model: + if backend in ("claude_code", "grok_build", "copilot") and model: kw["model"] = model return _CLI_PRESETS[backend](**kw) raise ValueError(f"unknown backend {backend!r}; choose from {BACKENDS}") @@ -813,16 +818,16 @@ async def orchestrate( orchestrator proposes criteria itself. backend: Worker engine — "auto" (default: same agent family as the caller when detectable), "claude_code", "codex", "opencode", - "aider", "grok_build", "claude_api", "grok_api", or "mock". + "aider", "grok_build", "copilot", "claude_api", "grok_api", or "mock". caller_agent: Optional caller identity hint for backend="auto", e.g. - "codex", "opencode", "claude", or "grok". Explicit backend overrides it. + "codex", "opencode", "copilot", "claude", or "grok". Explicit backend overrides it. cwd: Repo to work in. Required for coding tasks that edit files. max_iterations: Cap on decompose->review cycles (termination guard). skip_permissions: Let CLI workers use tools without prompting. Only meaningful with `cwd`; the run is isolated in a worktree. isolate: When `cwd` is set, run in a throwaway git worktree/branch so the caller's checkout is untouched (recommended). - model: Optional model override for claude_code / grok_build / claude_api / grok_api. + model: Optional model override for claude_code / grok_build / copilot / claude_api / grok_api. timeout: OPTIONAL seconds to cap EACH worker CLI subprocess call. None (default) = no per-call cap. Leave unset for normal runs; set it only to force a genuinely stuck worker to fail instead of hanging. diff --git a/examples/run_with_cli_agent.py b/examples/run_with_cli_agent.py index 7675043..7a1a6f7 100644 --- a/examples/run_with_cli_agent.py +++ b/examples/run_with_cli_agent.py @@ -6,6 +6,7 @@ python3 -m examples.run_with_cli_agent claude [REPO_DIR] python3 -m examples.run_with_cli_agent codex [REPO_DIR] python3 -m examples.run_with_cli_agent grok [REPO_DIR] + python3 -m examples.run_with_cli_agent copilot [REPO_DIR] python3 -m examples.run_with_cli_agent opencode Pass REPO_DIR to run the worker against a real repo. The run happens in an @@ -30,6 +31,7 @@ "opencode": CliAgent.opencode, "aider": CliAgent.aider, "grok": CliAgent.grok_build, + "copilot": CliAgent.copilot, } GOAL = "Add a /health endpoint that returns {status: ok} and a test for it." diff --git a/tests/test_cli_agent.py b/tests/test_cli_agent.py index b51c9f5..7a1dbbb 100644 --- a/tests/test_cli_agent.py +++ b/tests/test_cli_agent.py @@ -136,6 +136,8 @@ def test_skip_permissions_adds_bypass_flag(): assert "--yes-always" in CliAgent.aider(skip_permissions=True).command assert "--always-approve" in CliAgent.grok_build(skip_permissions=True).command assert "--always-approve" not in CliAgent.grok_build().command + assert "--allow-all-tools" in CliAgent.copilot(skip_permissions=True).command + assert "--allow-all-tools" not in CliAgent.copilot().command def test_grok_build_preset_shape(): @@ -146,6 +148,13 @@ def test_grok_build_preset_shape(): assert cmd[-2:] == ["-m", "grok-build-0.1"] +def test_copilot_preset_shape(): + cmd = CliAgent.copilot(model="gpt-5.4").command + assert cmd[:2] == ["copilot", "-p"] + assert "-s" in cmd and "--no-ask-user" in cmd + assert cmd[-2:] == ["--model", "gpt-5.4"] + + def test_build_agent_passes_skip_permissions_to_opencode(): from agentloop.mcp_server import _build_agent agent = _build_agent("opencode", cwd=None, skip_permissions=True, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 19c3a06..fb85522 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -132,6 +132,7 @@ def test_auto_backend_uses_caller_agent_hint(monkeypatch): assert _resolve_backend("auto", caller_agent="opencode") == "opencode" assert _resolve_backend("auto", caller_agent="claude") == "claude_code" assert _resolve_backend("auto", caller_agent="grok") == "grok_build" + assert _resolve_backend("auto", caller_agent="copilot") == "copilot" def test_auto_backend_uses_environment_hint(monkeypatch): @@ -149,6 +150,16 @@ def test_auto_backend_uses_grok_environment_hint(monkeypatch): assert _resolve_backend("auto") == "grok_build" +def test_auto_backend_uses_copilot_environment_hint(monkeypatch): + for name in ( + "OPENCODE", "OPENCODE_RUN_ID", "CODEX_RUN_ID", "CODEX_SESSION_ID", + "CLAUDECODE", "CLAUDE_CODE", "CLAUDE_SESSION_ID", "GROK_AGENT", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("COPILOT_CLI", "1") + assert _resolve_backend("auto") == "copilot" + + def test_explicit_backend_overrides_caller_hint(): assert _resolve_backend("codex", caller_agent="opencode") == "codex" @@ -158,6 +169,7 @@ def test_backend_aliases(): assert _resolve_backend("grok-build") == "grok_build" assert _resolve_backend("xai") == "grok_api" assert _resolve_backend("grok-api") == "grok_api" + assert _resolve_backend("copilot") == "copilot" def test_orchestrate_impl_reports_cli_loop_failure_after_intake_fallback(monkeypatch): @@ -218,6 +230,14 @@ def test_isolate_runs_in_worktree_and_reports_it(): def test_backends_listed(): assert "mock" in BACKENDS and "claude_code" in BACKENDS assert "grok_api" in BACKENDS and "grok_build" in BACKENDS + assert "copilot" in BACKENDS + + +def test_build_agent_forwards_model_to_copilot(): + from agentloop.mcp_server import _build_agent + agent = _build_agent("copilot", cwd=None, skip_permissions=True, + model="gpt-5.4", timeout=None) + assert "--model" in agent.command and "gpt-5.4" in agent.command def test_doctor_reports_backends_and_timeout_guidance(tmp_path):