From 5829d5f8b693c86d42eecfb5dc0ecf30921398d7 Mon Sep 17 00:00:00 2001 From: Lef Ioannidis Date: Mon, 20 Apr 2026 23:17:30 +0000 Subject: [PATCH 1/3] feat(examples): GitHub Copilot CLI agent (github-copilot-sdk==0.2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track A of copilot-cli-support plan. New example at examples/python_agent_nodes/copilot_agent/ that exposes GitHub Copilot CLI as a first-class AgentField node via the official github-copilot-sdk (Public Preview). Four reasoners: - ask — one-shot Q&A, no tools - plan — step-by-step plan without executing, no tools - review — inline diff (no tools) or file list (read-only allow-list) - run_task — full agent mode, requires allow_tools=True opt-in; allow_list beats deny_list Structured output is a stable dict (af_session_id, copilot_session_id, answer, transcript, tool_calls, usage, finished_reason, error) that insulates callers from SDK preview churn. Key design choices (post rubber-duck critique): - Default config_dir=None: reuses the user's real ~/.copilot so that skills placed by the new skillkit target, stored auth, and session resume are all visible. Opt-in isolation via isolate=True or AGENTFIELD_COPILOT_ISOLATE=1 uses a per-NODE (not per-run) sandbox, keeping concurrent reasoner calls on the same node coherent. - AF session id ↔ Copilot session id map kept in session-scoped memory under 'copilot_session_id', never reused directly. - Deny-by-default permission handler for every reasoner except run_task when the caller explicitly opts in. - Event discrimination via SessionEventType enum, never via isinstance on event.data — the Data dataclass is a union in v0.2.2 and subclass names are not exported. - Auth forwarded from COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN into SubprocessConfig(github_token=...). 21 unit tests (mocked CopilotClient, no Copilot subscription required in CI) plus a tests/test_sdk_compat.py smoke test that fails loudly if the preview SDK drifts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilot_agent/.env.example | 20 ++ .../copilot_agent/README.md | 132 ++++++++ .../copilot_agent/copilot_session.py | 288 ++++++++++++++++++ .../python_agent_nodes/copilot_agent/main.py | 35 +++ .../copilot_agent/pyproject.toml | 21 ++ .../copilot_agent/reasoners.py | 209 +++++++++++++ .../copilot_agent/tests/conftest.py | 128 ++++++++ .../tests/test_copilot_session.py | 260 ++++++++++++++++ .../copilot_agent/tests/test_reasoners.py | 135 ++++++++ .../copilot_agent/tests/test_sdk_compat.py | 82 +++++ 10 files changed, 1310 insertions(+) create mode 100644 examples/python_agent_nodes/copilot_agent/.env.example create mode 100644 examples/python_agent_nodes/copilot_agent/README.md create mode 100644 examples/python_agent_nodes/copilot_agent/copilot_session.py create mode 100644 examples/python_agent_nodes/copilot_agent/main.py create mode 100644 examples/python_agent_nodes/copilot_agent/pyproject.toml create mode 100644 examples/python_agent_nodes/copilot_agent/reasoners.py create mode 100644 examples/python_agent_nodes/copilot_agent/tests/conftest.py create mode 100644 examples/python_agent_nodes/copilot_agent/tests/test_copilot_session.py create mode 100644 examples/python_agent_nodes/copilot_agent/tests/test_reasoners.py create mode 100644 examples/python_agent_nodes/copilot_agent/tests/test_sdk_compat.py diff --git a/examples/python_agent_nodes/copilot_agent/.env.example b/examples/python_agent_nodes/copilot_agent/.env.example new file mode 100644 index 00000000..9cb2761f --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/.env.example @@ -0,0 +1,20 @@ +# Agent settings +AGENTFIELD_URL=http://localhost:8080 +AGENT_NODE_ID=copilot + +# Copilot model (default: gpt-5). Any model supported by the Copilot CLI is fine. +COPILOT_MODEL=gpt-5 + +# Copilot auth — pick ONE source (SDK precedence order): +# 1. COPILOT_GITHUB_TOKEN (explicit, dedicated) +# 2. GH_TOKEN / GITHUB_TOKEN (generic) +# 3. logged-in `copilot` user (run `copilot --login` once) +# Use a *fine-grained* PAT with the "Copilot Requests" permission. Classic +# ghp_* tokens will be rejected by the Copilot backend. +# COPILOT_GITHUB_TOKEN= + +# Opt-in isolation: when set to 1, each Copilot session uses a per-node +# config_dir under $AGENTFIELD_HOME/copilot-home/ instead of +# ~/.copilot. Default (unset) reuses the user's real ~/.copilot so that +# `af skill install` output and `copilot --login` auth are visible. +# AGENTFIELD_COPILOT_ISOLATE=0 diff --git a/examples/python_agent_nodes/copilot_agent/README.md b/examples/python_agent_nodes/copilot_agent/README.md new file mode 100644 index 00000000..8e7f5454 --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/README.md @@ -0,0 +1,132 @@ +# Copilot Agent — GitHub Copilot CLI as an AgentField node + +This example exposes **GitHub Copilot CLI** as a first-class AgentField agent +via the official [`github-copilot-sdk`](https://github.com/github/copilot-sdk) +(Public Preview). Other agents on the field can call Copilot the same way +they call any other AgentField reasoner — no subprocess parsing, no bespoke +protocol, full access to Copilot's streaming event model, permission hooks, +and skill discovery. + +## Reasoners + +| Reasoner | Tools | Use it for | +| --- | --- | --- | +| `ask(prompt, model?, cwd?, isolate?, continue_session?, timeout?)` | **None** | One-shot Q&A, text-only reply. | +| `plan(task, cwd?, model?, isolate?, timeout?)` | **None** | Step-by-step plan without executing anything. | +| `review(diff?, files?, cwd?, model?, isolate?, timeout?)` | Inline diff → **none**; file list → read-only allow-list (`read_file`, `list_directory`, `grep`, `git_diff`). | Code review. | +| `run_task(task, cwd?, model?, allow_tools=False, allow_list?, deny_list?, isolate?, continue_session?, timeout?)` | **Opt-in.** `allow_list` beats `deny_list`. | Full agent mode — Copilot plans *and* executes. | + +All reasoners return a stable, structured dict: + +```jsonc +{ + "af_session_id": "…", // AgentField session + "copilot_session_id": "…", // Copilot session (mapped via memory) + "model": "gpt-5", + "answer": "…", + "transcript": [{ "role": "assistant", "content": "…" }, …], + "tool_calls": [{ "tool_name": "read_file", "tool_call_id": "…", "status": "complete" }], + "usage": { "input_tokens": 0, "output_tokens": 0 }, + "finished_reason": "idle|error|timeout|aborted", + "error": null +} +``` + +## Quickstart + +```bash +cd examples/python_agent_nodes/copilot_agent +python -m venv .venv && source .venv/bin/activate +pip install -e .[test] + +cp .env.example .env # edit if needed +export $(grep -v '^#' .env | xargs) + +# Optional: one-time Copilot login (the SDK will reuse this). +copilot --login + +# Start the control plane separately, then run the agent: +python main.py +``` + +`af run` also works; the example registers as node id `copilot` by default. + +### Calling from another agent + +```python +from agentfield import Client + +client = Client("http://localhost:8080") +resp = await client.call("copilot.ask", {"prompt": "Explain CRDTs in one paragraph."}) +print(resp["answer"]) + +# Full agent mode — must opt in to tools: +resp = await client.call("copilot.run_task", { + "task": "Run the unit tests and summarize failures.", + "cwd": "/workspace", + "allow_tools": True, + "allow_list": ["read_file", "list_directory", "grep", "run_tests"], + "timeout": 600, +}) +``` + +## Authentication + +The Copilot SDK tries the following in order: + +1. `COPILOT_GITHUB_TOKEN` +2. `GH_TOKEN` +3. `GITHUB_TOKEN` +4. Logged-in user from `~/.copilot` (run `copilot --login` once) + +**Token type matters.** Copilot **rejects classic `ghp_*` PATs** — use a +**fine-grained PAT with the "Copilot Requests" permission** or the +interactive login. `af doctor` reports which *sources* are present but +never claims the session is actually authenticated; only `copilot --login` +(or a real request) confirms that. + +AgentField has no secrets store by design. Tokens live in the agent +process environment and nowhere else. + +## Isolation + +By default the agent uses your real `~/.copilot/` directory so that: + +- skills installed by `af skill install` (including via this repo's + `copilot` skillkit target) are visible to the SDK; +- auth from `copilot --login` is reused; +- session resume works across restarts. + +Set `AGENTFIELD_COPILOT_ISOLATE=1` (env) or pass `isolate=True` to any +reasoner to use a per-node sandbox under `$AGENTFIELD_HOME/copilot-home/` +instead. Isolation is **per-node**, not per-run, so concurrent reasoner +calls on the same agent share one sandbox rather than each bootstrapping +an empty one. + +## Continuing a conversation + +`ask` and `run_task` accept `continue_session=True`. The AgentField session +id is mapped to a Copilot session id via session-scoped memory (key +`copilot_session_id`). Different AgentField sessions always get +independent Copilot sessions. + +## Testing + +```bash +pip install -e .[test] +pytest +``` + +The 21-test suite mocks `copilot.CopilotClient` so no Copilot subscription +is required in CI. `tests/test_sdk_compat.py` is a compatibility smoke +test that imports every SDK symbol this example depends on and fails +loudly if the preview SDK drifts — pin `github-copilot-sdk==0.2.2` in +`pyproject.toml`. + +## See also + +- `research/docs/2026-04-20-copilot-cli-support.md` — architecture overview. +- `control-plane/internal/skillkit/target_copilot.go` — how `af skill install` + ships skills into `~/.copilot/skills/` for Copilot to discover. +- `af doctor --json | jq .copilot` — inspect Copilot auth/config on the + local machine. diff --git a/examples/python_agent_nodes/copilot_agent/copilot_session.py b/examples/python_agent_nodes/copilot_agent/copilot_session.py new file mode 100644 index 00000000..fcc6db01 --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/copilot_session.py @@ -0,0 +1,288 @@ +"""Thin wrapper around github-copilot-sdk 0.2.2 that turns a Copilot session +into an AgentField-shaped structured response. + +Design decisions (see research/docs/2026-04-20-copilot-cli-support.md and the +rubber-duck critique in the plan): + +* By default, do NOT set ``config_dir`` on the session — Copilot uses the + user's real ``~/.copilot/`` so skills installed by ``af skill install`` + and auth from ``copilot --login`` are visible. +* Opt-in isolation via ``isolate=True`` or ``AGENTFIELD_COPILOT_ISOLATE=1`` + uses a per-node directory, not per-run, so concurrent reasoner calls on + the same agent share the same sandbox rather than each getting a fresh + (auth-less, skill-less) one. +* The AgentField session id is mapped to a Copilot session id via + session-scoped memory, keeping the two namespaces separate. +* Events are discriminated by ``SessionEventType`` (stable enum), never by + isinstance on ``event.data`` — the Data dataclass is a union in v0.2.2 + and subclass names are not exported. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any, Optional +from uuid import uuid4 + +from copilot import CopilotClient, SubprocessConfig +from copilot.session import ( + PermissionHandler, + PermissionRequestResult, +) +from copilot.generated.session_events import ( + SessionEvent, + SessionEventType, +) + + +def _deny_all_handler(request: Any, invocation: dict[str, str]) -> PermissionRequestResult: + """Permission handler that denies every tool invocation. + + Used by reasoners that must not execute anything (``ask``, ``plan``). + Also used as the default when no explicit policy is requested. + """ + return PermissionRequestResult( + kind="denied", + message="tool execution not permitted in this reasoner", + ) + + +@dataclass +class CopilotRunResult: + """Structured output returned by :func:`run_copilot`. + + Stable across SDK preview churn — the Copilot SDK's own event-data + classes are not re-exported here. + """ + + af_session_id: Optional[str] + copilot_session_id: str + model: Optional[str] + answer: str + transcript: list[dict[str, Any]] = field(default_factory=list) + tool_calls: list[dict[str, Any]] = field(default_factory=list) + usage: dict[str, int] = field(default_factory=dict) + finished_reason: str = "idle" # "idle" | "error" | "timeout" | "aborted" + error: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + return { + "af_session_id": self.af_session_id, + "copilot_session_id": self.copilot_session_id, + "model": self.model, + "answer": self.answer, + "transcript": self.transcript, + "tool_calls": self.tool_calls, + "usage": self.usage, + "finished_reason": self.finished_reason, + "error": self.error, + } + + +def _build_subprocess_config() -> Optional[SubprocessConfig]: + """Forward a Copilot GitHub token from the agent env if present. + + Auth precedence mirrors the SDK: + 1. ``COPILOT_GITHUB_TOKEN`` + 2. ``GH_TOKEN`` + 3. ``GITHUB_TOKEN`` + 4. logged-in ``copilot`` user (no env needed — SDK picks it up) + """ + for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"): + token = os.getenv(var, "").strip() + if token: + return SubprocessConfig(github_token=token) + return None + + +def _resolve_config_dir(isolate: bool, node_id: str) -> Optional[str]: + """Decide which Copilot ``config_dir`` to use. + + Returns ``None`` when the caller should reuse the user's real + ``~/.copilot/``. Returns a per-node sandbox path when ``isolate=True`` + or ``AGENTFIELD_COPILOT_ISOLATE=1``. + """ + env_flag = os.getenv("AGENTFIELD_COPILOT_ISOLATE", "").strip().lower() + if not isolate and env_flag not in ("1", "true", "yes", "on"): + return None + home = os.getenv("AGENTFIELD_HOME") or os.path.expanduser("~/.agentfield") + path = os.path.join(home, "copilot-home", node_id) + os.makedirs(path, exist_ok=True) + return path + + +async def _resolve_copilot_session_id( + app: Any, + af_session_id: Optional[str], + continue_session: bool, +) -> str: + """Map the AgentField session to a Copilot session id. + + Stored in session-scoped memory under + ``copilot_session_id:``. Fresh uuid when no mapping + exists, or when ``continue_session`` is False. + """ + if not af_session_id or not continue_session or app is None or app.memory is None: + return str(uuid4()) + try: + scoped = app.memory.session(af_session_id) + existing = await scoped.get("copilot_session_id") + if isinstance(existing, str) and existing: + return existing + new_id = str(uuid4()) + await scoped.set("copilot_session_id", new_id) + return new_id + except Exception: + # Memory is best-effort for session mapping; fall back to a fresh id. + return str(uuid4()) + + +def _assistant_text(event: SessionEvent) -> Optional[str]: + """Extract the final assistant message text from an event, if any.""" + data = event.data + for attr in ("content", "message", "transformed_content", "summary_content"): + val = getattr(data, attr, None) + if isinstance(val, str) and val.strip(): + return val + return None + + +async def run_copilot( + *, + app: Any, + prompt: str, + node_id: str, + af_session_id: Optional[str] = None, + cwd: Optional[str] = None, + model: Optional[str] = None, + isolate: bool = False, + continue_session: bool = False, + available_tools: Optional[list[str]] = None, + excluded_tools: Optional[list[str]] = None, + system_message: Optional[str] = None, + permission_handler: Any = _deny_all_handler, + timeout: float = 120.0, +) -> CopilotRunResult: + """Run a single Copilot turn and return a structured result. + + ``available_tools=[]`` forces Copilot into a read-only / pure-reasoning + mode; combined with ``permission_handler=_deny_all_handler`` this makes + the reasoner safe by default. Callers that want full agent mode pass + ``permission_handler=PermissionHandler.approve_all`` and either leave + ``available_tools=None`` (all first-party tools) or provide an allow + list. + """ + copilot_session_id = await _resolve_copilot_session_id( + app, af_session_id, continue_session + ) + config_dir = _resolve_config_dir(isolate, node_id) + subprocess_config = _build_subprocess_config() + + client_kwargs: dict[str, Any] = {} + if subprocess_config is not None: + client_kwargs["subprocess_config"] = subprocess_config + + transcript: list[dict[str, Any]] = [] + tool_calls: list[dict[str, Any]] = [] + usage: dict[str, int] = {} + error_msg: Optional[str] = None + + def _on_event(event: SessionEvent) -> None: + et = event.type + data = event.data + if et == SessionEventType.ASSISTANT_MESSAGE: + text = _assistant_text(event) + if text: + transcript.append({"role": "assistant", "content": text}) + elif et == SessionEventType.TOOL_EXECUTION_START: + tool_calls.append( + { + "tool_call_id": getattr(data, "tool_call_id", None), + "tool_name": getattr(data, "tool_name", None), + "status": "started", + } + ) + transcript.append( + { + "role": "tool_call", + "tool_name": getattr(data, "tool_name", None), + "tool_call_id": getattr(data, "tool_call_id", None), + } + ) + elif et == SessionEventType.TOOL_EXECUTION_COMPLETE: + tool_call_id = getattr(data, "tool_call_id", None) + for tc in tool_calls: + if tc.get("tool_call_id") == tool_call_id: + tc["status"] = "complete" + break + elif et == SessionEventType.ASSISTANT_USAGE: + for k in ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens"): + v = getattr(data, k, None) + if isinstance(v, int): + usage[k] = usage.get(k, 0) + v + elif et == SessionEventType.SESSION_ERROR: + nonlocal error_msg # noqa: PLW0603 + error_msg = getattr(data, "message", None) or "unknown session error" + + async with CopilotClient(**client_kwargs) as client: + session_kwargs: dict[str, Any] = { + "on_permission_request": permission_handler, + "session_id": copilot_session_id, + "on_event": _on_event, + } + if model: + session_kwargs["model"] = model + if cwd: + session_kwargs["working_directory"] = cwd + if config_dir: + session_kwargs["config_dir"] = config_dir + if available_tools is not None: + session_kwargs["available_tools"] = available_tools + if excluded_tools is not None: + session_kwargs["excluded_tools"] = excluded_tools + if system_message: + session_kwargs["system_message"] = system_message + + async with await client.create_session(**session_kwargs) as session: + try: + final_event = await session.send_and_wait(prompt, timeout=timeout) + except TimeoutError as exc: + return CopilotRunResult( + af_session_id=af_session_id, + copilot_session_id=copilot_session_id, + model=model, + answer="", + transcript=transcript, + tool_calls=tool_calls, + usage=usage, + finished_reason="timeout", + error=str(exc), + ) + + answer = "" + for entry in reversed(transcript): + if entry.get("role") == "assistant" and isinstance(entry.get("content"), str): + answer = entry["content"] + break + if not answer and final_event is not None: + answer = _assistant_text(final_event) or "" + + finished_reason = "idle" + if error_msg: + finished_reason = "error" + elif final_event is not None and final_event.type == SessionEventType.SESSION_ERROR: + finished_reason = "error" + error_msg = error_msg or _assistant_text(final_event) or "session error" + + return CopilotRunResult( + af_session_id=af_session_id, + copilot_session_id=copilot_session_id, + model=model, + answer=answer, + transcript=transcript, + tool_calls=tool_calls, + usage=usage, + finished_reason=finished_reason, + error=error_msg, + ) diff --git a/examples/python_agent_nodes/copilot_agent/main.py b/examples/python_agent_nodes/copilot_agent/main.py new file mode 100644 index 00000000..6c54d1f4 --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/main.py @@ -0,0 +1,35 @@ +"""GitHub Copilot CLI as an AgentField node. + +Boot entry point. Registers the ``ask``, ``plan``, ``review`` and +``run_task`` reasoners on a single :class:`agentfield.Agent` and starts +the HTTP server via ``app.run(auto_port=True)`` (same convention as the +other Python examples). +""" + +from __future__ import annotations + +import os + +from agentfield import Agent + +from reasoners import register + + +app = Agent( + node_id=os.getenv("AGENT_NODE_ID", "copilot"), + agentfield_server=os.getenv("AGENTFIELD_URL", "http://localhost:8080"), +) + +register(app) + + +if __name__ == "__main__": + print("🤖 GitHub Copilot CLI agent") + print(f"📍 Node: {app.node_id}") + print(f"🌐 Control Plane: {os.getenv('AGENTFIELD_URL', 'http://localhost:8080')}") + print(f"🧠 Default model: {os.getenv('COPILOT_MODEL', 'gpt-5')}") + isolate = os.getenv("AGENTFIELD_COPILOT_ISOLATE", "").strip().lower() in ( + "1", "true", "yes", "on" + ) + print(f"🔒 Isolation (config_dir): {'per-node' if isolate else 'shared ~/.copilot'}") + app.run(auto_port=True) diff --git a/examples/python_agent_nodes/copilot_agent/pyproject.toml b/examples/python_agent_nodes/copilot_agent/pyproject.toml new file mode 100644 index 00000000..cdf98a4d --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "agentfield-copilot-agent" +version = "0.1.0" +description = "AgentField example agent backed by GitHub Copilot CLI via github-copilot-sdk." +requires-python = ">=3.10" +readme = "README.md" +dependencies = [ + "agentfield", + # Pinned to the verified API surface. The repo's main samples reference + # event-data classes (AssistantMessageData, SessionIdleData) that do NOT + # exist in the PyPI build; we discriminate events via SessionEventType + # (stable, schema-generated). + "github-copilot-sdk==0.2.2", + "pydantic>=2.0", +] + +[project.optional-dependencies] +test = ["pytest>=7", "pytest-asyncio>=0.23"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/examples/python_agent_nodes/copilot_agent/reasoners.py b/examples/python_agent_nodes/copilot_agent/reasoners.py new file mode 100644 index 00000000..af0e1359 --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/reasoners.py @@ -0,0 +1,209 @@ +"""AgentField reasoners that expose GitHub Copilot CLI as a first-class node. + +Permission posture per rubber-duck finding #3: + +* ``ask`` / ``plan`` — no tools available, deny-by-default permission handler. +* ``review`` — narrow read-only allow list. +* ``run_task`` — opt-in ``allow_tools=True`` required; defaults to safe. + +Every reasoner returns :class:`CopilotRunResult` serialized to a dict so the +structured fields survive the AgentField reasoner boundary even as the +underlying Copilot SDK (Public Preview) evolves. +""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +from copilot.session import PermissionHandler + +from copilot_session import CopilotRunResult, run_copilot, _deny_all_handler + + +_DEFAULT_MODEL = os.getenv("COPILOT_MODEL", "gpt-5") + + +def register(app: Any) -> None: + """Register all reasoners on the given AgentField :class:`Agent`.""" + + node_id = getattr(app, "node_id", None) or os.getenv("AGENT_NODE_ID", "copilot") + + async def _run(**kwargs: Any) -> dict[str, Any]: + ctx = kwargs.pop("_ctx", None) + af_session_id = getattr(ctx, "session_id", None) if ctx is not None else None + result: CopilotRunResult = await run_copilot( + app=app, + af_session_id=af_session_id, + node_id=node_id, + **kwargs, + ) + return result.to_dict() + + @app.reasoner() + async def ask( + prompt: str, + model: Optional[str] = None, + cwd: Optional[str] = None, + isolate: bool = False, + continue_session: bool = False, + timeout: float = 60.0, + execution_context: Any = None, + ) -> dict[str, Any]: + """One-shot Q&A. No tool execution, no filesystem access. + + Use this when you want Copilot to reason over a prompt and respond + with text only. + """ + return await _run( + prompt=prompt, + model=model or _DEFAULT_MODEL, + cwd=cwd, + isolate=isolate, + continue_session=continue_session, + available_tools=[], + permission_handler=_deny_all_handler, + timeout=timeout, + _ctx=execution_context, + ) + + @app.reasoner() + async def plan( + task: str, + cwd: Optional[str] = None, + model: Optional[str] = None, + isolate: bool = False, + timeout: float = 120.0, + execution_context: Any = None, + ) -> dict[str, Any]: + """Ask Copilot to produce a step-by-step plan without executing tools.""" + prompt = ( + "Produce a detailed step-by-step plan for the following task. " + "Do NOT execute anything — output plan text only.\n\n" + f"Task:\n{task}" + ) + return await _run( + prompt=prompt, + model=model or _DEFAULT_MODEL, + cwd=cwd, + isolate=isolate, + available_tools=[], + permission_handler=_deny_all_handler, + timeout=timeout, + _ctx=execution_context, + ) + + @app.reasoner() + async def review( + diff: Optional[str] = None, + files: Optional[list[str]] = None, + cwd: Optional[str] = None, + model: Optional[str] = None, + isolate: bool = False, + timeout: float = 180.0, + execution_context: Any = None, + ) -> dict[str, Any]: + """Review a diff or a set of files. Read-only tools allowed.""" + if diff: + prompt = ( + "Review the following diff for bugs, security issues, and " + "logic errors. Return a numbered list of findings.\n\n" + f"```diff\n{diff}\n```" + ) + # With an inline diff we do not need any tools. + return await _run( + prompt=prompt, + model=model or _DEFAULT_MODEL, + cwd=cwd, + isolate=isolate, + available_tools=[], + permission_handler=_deny_all_handler, + timeout=timeout, + _ctx=execution_context, + ) + + if not files: + return { + "error": "review requires either `diff` or `files`", + "af_session_id": None, + "copilot_session_id": "", + "model": model or _DEFAULT_MODEL, + "answer": "", + "transcript": [], + "tool_calls": [], + "usage": {}, + "finished_reason": "error", + } + + file_list = "\n".join(f"- {p}" for p in files) + prompt = ( + "Review the following files in the working tree. Read them, then " + "produce a numbered list of findings (bugs, security issues, " + "logic errors). Do not modify any files.\n\n" + f"Files to review:\n{file_list}" + ) + return await _run( + prompt=prompt, + model=model or _DEFAULT_MODEL, + cwd=cwd, + isolate=isolate, + available_tools=["read_file", "list_directory", "grep", "git_diff"], + permission_handler=PermissionHandler.approve_all, + timeout=timeout, + _ctx=execution_context, + ) + + @app.reasoner() + async def run_task( + task: str, + cwd: Optional[str] = None, + model: Optional[str] = None, + allow_tools: bool = False, + allow_list: Optional[list[str]] = None, + deny_list: Optional[list[str]] = None, + isolate: bool = False, + continue_session: bool = False, + timeout: float = 600.0, + execution_context: Any = None, + ) -> dict[str, Any]: + """Full agent mode — Copilot plans and executes. + + Dangerous by default, so ``allow_tools`` must be set explicitly. + When ``allow_list`` is provided it takes precedence over + ``deny_list`` (allow-lists are strictly safer). + """ + if not allow_tools: + return { + "error": "run_task requires allow_tools=True; tool execution refused.", + "af_session_id": getattr(execution_context, "session_id", None), + "copilot_session_id": "", + "model": model or _DEFAULT_MODEL, + "answer": "", + "transcript": [], + "tool_calls": [], + "usage": {}, + "finished_reason": "error", + } + + available = allow_list if allow_list else None + excluded = None if allow_list else deny_list + + return await _run( + prompt=task, + model=model or _DEFAULT_MODEL, + cwd=cwd, + isolate=isolate, + continue_session=continue_session, + available_tools=available, + excluded_tools=excluded, + permission_handler=PermissionHandler.approve_all, + timeout=timeout, + _ctx=execution_context, + ) + + # Expose reasoners on the registration result so tests can reach them + # directly without going through the HTTP layer. + register.ask = ask + register.plan = plan + register.review = review + register.run_task = run_task diff --git a/examples/python_agent_nodes/copilot_agent/tests/conftest.py b/examples/python_agent_nodes/copilot_agent/tests/conftest.py new file mode 100644 index 00000000..30762a0b --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/tests/conftest.py @@ -0,0 +1,128 @@ +"""Shared pytest fixtures for the copilot-agent example.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Callable + +import pytest + +# Make the example package importable as flat modules (`copilot_session`, +# `reasoners`) because the example ships as a script-style project rather +# than an installable package. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +class _FakeScopedMemory: + def __init__(self, store: dict[str, Any]): + self._store = store + + async def get(self, key: str, default: Any = None) -> Any: + return self._store.get(key, default) + + async def set(self, key: str, value: Any) -> None: + self._store[key] = value + + +class _FakeMemory: + def __init__(self) -> None: + self.sessions: dict[str, dict[str, Any]] = {} + + def session(self, session_id: str) -> _FakeScopedMemory: + return _FakeScopedMemory(self.sessions.setdefault(session_id, {})) + + +class _FakeApp: + """Minimal AgentField Agent stub for reasoner tests.""" + + def __init__(self, node_id: str = "copilot-test") -> None: + self.node_id = node_id + self.memory = _FakeMemory() + self._reasoners: dict[str, Callable[..., Any]] = {} + + def reasoner(self) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def wrap(fn: Callable[..., Any]) -> Callable[..., Any]: + self._reasoners[fn.__name__] = fn + return fn + return wrap + + +@pytest.fixture +def fake_app() -> _FakeApp: + return _FakeApp() + + +@pytest.fixture +def stub_copilot_client(monkeypatch: pytest.MonkeyPatch): + """Replace ``CopilotClient`` with an async-context stub that fires a + scripted sequence of :class:`SessionEvent`-shaped objects through the + caller's ``on_event`` callback. + + Returns a helper that the test configures with ``events`` and + ``final_event`` before invoking the reasoner/wrapper. + """ + + state: dict[str, Any] = { + "events": [], + "final_event": None, + "raise_timeout": False, + "captured_session_kwargs": None, + "captured_prompt": None, + } + + class _FakeSession: + def __init__(self, on_event: Callable[[Any], None]) -> None: + self._on_event = on_event + + async def __aenter__(self) -> "_FakeSession": + return self + + async def __aexit__(self, *_a: Any) -> None: + return None + + async def send_and_wait( + self, + prompt: str, + *, + attachments: Any = None, + mode: Any = None, + timeout: float = 60.0, + ) -> Any: + state["captured_prompt"] = prompt + if state["raise_timeout"]: + raise TimeoutError("fake timeout") + for ev in state["events"]: + self._on_event(ev) + return state["final_event"] + + class _FakeClient: + def __init__(self, *a: Any, **kw: Any) -> None: + pass + + async def __aenter__(self) -> "_FakeClient": + return self + + async def __aexit__(self, *_a: Any) -> None: + return None + + async def create_session(self, **kwargs: Any) -> _FakeSession: + state["captured_session_kwargs"] = kwargs + return _FakeSession(kwargs["on_event"]) + + import copilot_session as cs + monkeypatch.setattr(cs, "CopilotClient", _FakeClient) + return state + + +def make_event(event_type: Any, **data_fields: Any) -> SimpleNamespace: + """Build a minimal duck-typed SessionEvent for the wrapper tests.""" + return SimpleNamespace( + type=event_type, + data=SimpleNamespace(**data_fields), + id="e1", + timestamp="now", + ephemeral=False, + parent_id=None, + ) diff --git a/examples/python_agent_nodes/copilot_agent/tests/test_copilot_session.py b/examples/python_agent_nodes/copilot_agent/tests/test_copilot_session.py new file mode 100644 index 00000000..86490b07 --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/tests/test_copilot_session.py @@ -0,0 +1,260 @@ +"""Unit tests for the :mod:`copilot_session` wrapper using a stubbed SDK.""" + +from __future__ import annotations + +import os + +import pytest + +pytest.importorskip("copilot") + +from copilot.generated.session_events import SessionEventType # noqa: E402 + +from conftest import make_event # noqa: E402 + + +@pytest.mark.asyncio +async def test_ask_happy_path(fake_app, stub_copilot_client, monkeypatch): + import copilot_session as cs + + stub_copilot_client["events"] = [ + make_event(SessionEventType.ASSISTANT_MESSAGE, content="Hello, world."), + make_event(SessionEventType.ASSISTANT_USAGE, input_tokens=10, output_tokens=3), + ] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + result = await cs.run_copilot( + app=fake_app, + prompt="say hi", + node_id="copilot-test", + af_session_id="s-1", + model="gpt-5", + available_tools=[], + ) + + assert result.finished_reason == "idle" + assert result.answer == "Hello, world." + assert result.usage == {"input_tokens": 10, "output_tokens": 3} + assert result.copilot_session_id + # Permission handler defaults to the deny-all handler defined in the module. + kwargs = stub_copilot_client["captured_session_kwargs"] + assert kwargs["on_permission_request"] is cs._deny_all_handler + assert kwargs["available_tools"] == [] + assert stub_copilot_client["captured_prompt"] == "say hi" + + +@pytest.mark.asyncio +async def test_timeout_path(fake_app, stub_copilot_client): + import copilot_session as cs + + stub_copilot_client["raise_timeout"] = True + + result = await cs.run_copilot( + app=fake_app, + prompt="slow", + node_id="copilot-test", + af_session_id="s-1", + timeout=0.01, + ) + + assert result.finished_reason == "timeout" + assert "fake timeout" in (result.error or "") + assert result.answer == "" + + +@pytest.mark.asyncio +async def test_tool_calls_collected(fake_app, stub_copilot_client): + import copilot_session as cs + + stub_copilot_client["events"] = [ + make_event( + SessionEventType.TOOL_EXECUTION_START, + tool_call_id="tc-1", + tool_name="read_file", + ), + make_event( + SessionEventType.TOOL_EXECUTION_COMPLETE, + tool_call_id="tc-1", + tool_name="read_file", + ), + make_event(SessionEventType.ASSISTANT_MESSAGE, content="done"), + ] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + result = await cs.run_copilot( + app=fake_app, + prompt="do it", + node_id="copilot-test", + af_session_id="s-1", + ) + + assert len(result.tool_calls) == 1 + assert result.tool_calls[0]["status"] == "complete" + assert result.tool_calls[0]["tool_name"] == "read_file" + assert result.answer == "done" + + +@pytest.mark.asyncio +async def test_session_error_sets_error_finish(fake_app, stub_copilot_client): + import copilot_session as cs + + stub_copilot_client["events"] = [ + make_event(SessionEventType.SESSION_ERROR, message="boom"), + ] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + result = await cs.run_copilot( + app=fake_app, + prompt="bad", + node_id="copilot-test", + af_session_id="s-1", + ) + + assert result.finished_reason == "error" + assert result.error == "boom" + + +@pytest.mark.asyncio +async def test_auth_env_forwarded_to_subprocess_config( + fake_app, stub_copilot_client, monkeypatch +): + import copilot_session as cs + + monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp-fake-token") + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + stub_copilot_client["events"] = [] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + # Capture the CopilotClient kwargs by monkeypatching the __init__. + captured: dict = {} + real_client = cs.CopilotClient + + class _Client(real_client): # type: ignore[misc,valid-type] + def __init__(self, *a, **kw): # noqa: D401 + captured["args"] = a + captured["kwargs"] = kw + + monkeypatch.setattr(cs, "CopilotClient", _Client) + + await cs.run_copilot( + app=fake_app, + prompt="ok", + node_id="copilot-test", + af_session_id="s-1", + ) + + cfg = captured["kwargs"].get("subprocess_config") + assert cfg is not None + assert cfg.github_token == "ghp-fake-token" + + +@pytest.mark.asyncio +async def test_isolation_opt_in_creates_per_node_config_dir( + fake_app, stub_copilot_client, monkeypatch, tmp_path +): + import copilot_session as cs + + monkeypatch.setenv("AGENTFIELD_HOME", str(tmp_path)) + monkeypatch.delenv("AGENTFIELD_COPILOT_ISOLATE", raising=False) + + stub_copilot_client["events"] = [] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + # Default: no config_dir is passed (shared ~/.copilot). + await cs.run_copilot( + app=fake_app, + prompt="ok", + node_id="copilot-nodeA", + af_session_id="s-1", + ) + kwargs = stub_copilot_client["captured_session_kwargs"] + assert "config_dir" not in kwargs + + # Explicit isolate=True creates a per-node sandbox. + await cs.run_copilot( + app=fake_app, + prompt="ok", + node_id="copilot-nodeA", + af_session_id="s-2", + isolate=True, + ) + kwargs = stub_copilot_client["captured_session_kwargs"] + assert kwargs["config_dir"] == str(tmp_path / "copilot-home" / "copilot-nodeA") + assert os.path.isdir(kwargs["config_dir"]) + + +@pytest.mark.asyncio +async def test_session_id_mapping_in_memory(fake_app, stub_copilot_client): + import copilot_session as cs + + stub_copilot_client["events"] = [] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + # First call with continue_session=False always gets a fresh id. + r1 = await cs.run_copilot( + app=fake_app, + prompt="ok", + node_id="copilot-nodeA", + af_session_id="af-1", + continue_session=False, + ) + # Second call with continue_session=True but nothing stored → fresh id, + # then persisted. + r2 = await cs.run_copilot( + app=fake_app, + prompt="ok", + node_id="copilot-nodeA", + af_session_id="af-1", + continue_session=True, + ) + # Third call with continue_session=True reuses r2's id. + r3 = await cs.run_copilot( + app=fake_app, + prompt="ok", + node_id="copilot-nodeA", + af_session_id="af-1", + continue_session=True, + ) + + assert r1.copilot_session_id != r2.copilot_session_id + assert r2.copilot_session_id == r3.copilot_session_id + # Different AF session id → different mapping. + r4 = await cs.run_copilot( + app=fake_app, + prompt="ok", + node_id="copilot-nodeA", + af_session_id="af-2", + continue_session=True, + ) + assert r4.copilot_session_id != r2.copilot_session_id + + +@pytest.mark.asyncio +async def test_to_dict_round_trip(fake_app, stub_copilot_client): + import copilot_session as cs + + stub_copilot_client["events"] = [ + make_event(SessionEventType.ASSISTANT_MESSAGE, content="hi"), + ] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + result = await cs.run_copilot( + app=fake_app, + prompt="p", + node_id="n", + af_session_id="s", + ) + d = result.to_dict() + assert set(d.keys()) == { + "af_session_id", + "copilot_session_id", + "model", + "answer", + "transcript", + "tool_calls", + "usage", + "finished_reason", + "error", + } diff --git a/examples/python_agent_nodes/copilot_agent/tests/test_reasoners.py b/examples/python_agent_nodes/copilot_agent/tests/test_reasoners.py new file mode 100644 index 00000000..543a503f --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/tests/test_reasoners.py @@ -0,0 +1,135 @@ +"""Unit tests for the four reasoners, exercised via the registration helper.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("copilot") + +from copilot.generated.session_events import SessionEventType # noqa: E402 + +from conftest import make_event # noqa: E402 + + +def _register(fake_app): + import reasoners + + reasoners.register(fake_app) + return reasoners + + +@pytest.mark.asyncio +async def test_ask_has_no_tools(fake_app, stub_copilot_client): + r = _register(fake_app) + stub_copilot_client["events"] = [ + make_event(SessionEventType.ASSISTANT_MESSAGE, content="42"), + ] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + out = await r.register.ask(prompt="what is the answer?") + assert out["answer"] == "42" + kwargs = stub_copilot_client["captured_session_kwargs"] + assert kwargs["available_tools"] == [] + + +@pytest.mark.asyncio +async def test_plan_has_no_tools(fake_app, stub_copilot_client): + r = _register(fake_app) + stub_copilot_client["events"] = [ + make_event(SessionEventType.ASSISTANT_MESSAGE, content="step 1; step 2"), + ] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + out = await r.register.plan(task="bake a cake") + assert "step 1" in out["answer"] + kwargs = stub_copilot_client["captured_session_kwargs"] + assert kwargs["available_tools"] == [] + # Prompt wraps the task with a no-execute instruction. + assert "step-by-step plan" in stub_copilot_client["captured_prompt"] + assert "bake a cake" in stub_copilot_client["captured_prompt"] + + +@pytest.mark.asyncio +async def test_review_with_diff_disables_tools(fake_app, stub_copilot_client): + r = _register(fake_app) + stub_copilot_client["events"] = [ + make_event(SessionEventType.ASSISTANT_MESSAGE, content="1. nit: rename foo"), + ] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + out = await r.register.review(diff="- foo\n+ bar\n") + assert "nit" in out["answer"] + kwargs = stub_copilot_client["captured_session_kwargs"] + assert kwargs["available_tools"] == [] + + +@pytest.mark.asyncio +async def test_review_with_files_uses_readonly_allowlist(fake_app, stub_copilot_client): + r = _register(fake_app) + stub_copilot_client["events"] = [ + make_event(SessionEventType.ASSISTANT_MESSAGE, content="ok"), + ] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + await r.register.review(files=["a.py", "b.py"], cwd="/tmp") + kwargs = stub_copilot_client["captured_session_kwargs"] + assert set(kwargs["available_tools"]) == { + "read_file", + "list_directory", + "grep", + "git_diff", + } + + +@pytest.mark.asyncio +async def test_review_requires_diff_or_files(fake_app, stub_copilot_client): + r = _register(fake_app) + out = await r.register.review() + assert out["finished_reason"] == "error" + assert "requires" in out["error"] + # No session should have been created. + assert stub_copilot_client["captured_session_kwargs"] is None + + +@pytest.mark.asyncio +async def test_run_task_denies_without_allow_tools(fake_app, stub_copilot_client): + r = _register(fake_app) + + out = await r.register.run_task(task="delete all files") + assert out["finished_reason"] == "error" + assert "allow_tools=True" in out["error"] + assert stub_copilot_client["captured_session_kwargs"] is None + + +@pytest.mark.asyncio +async def test_run_task_allow_list_beats_deny_list(fake_app, stub_copilot_client): + r = _register(fake_app) + stub_copilot_client["events"] = [] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + await r.register.run_task( + task="do it", + allow_tools=True, + allow_list=["read_file"], + deny_list=["execute_shell_command"], + ) + kwargs = stub_copilot_client["captured_session_kwargs"] + assert kwargs["available_tools"] == ["read_file"] + # excluded_tools must NOT be set when allow_list is provided. + assert "excluded_tools" not in kwargs + + +@pytest.mark.asyncio +async def test_run_task_deny_list_only(fake_app, stub_copilot_client): + r = _register(fake_app) + stub_copilot_client["events"] = [] + stub_copilot_client["final_event"] = make_event(SessionEventType.SESSION_IDLE) + + await r.register.run_task( + task="do it", + allow_tools=True, + deny_list=["execute_shell_command"], + ) + kwargs = stub_copilot_client["captured_session_kwargs"] + assert "available_tools" not in kwargs + assert kwargs["excluded_tools"] == ["execute_shell_command"] diff --git a/examples/python_agent_nodes/copilot_agent/tests/test_sdk_compat.py b/examples/python_agent_nodes/copilot_agent/tests/test_sdk_compat.py new file mode 100644 index 00000000..73883963 --- /dev/null +++ b/examples/python_agent_nodes/copilot_agent/tests/test_sdk_compat.py @@ -0,0 +1,82 @@ +"""Compatibility smoke test for ``github-copilot-sdk==0.2.2``. + +Asserts that every SDK symbol this example depends on exists and has the +expected shape. If the PyPI package drifts — e.g. renames +``send_and_wait`` or removes a ``SessionEventType`` value — this test +fails loudly BEFORE users hit an opaque ``AttributeError`` at runtime. + +Per rubber-duck finding #6 ("SDK preview risk"). +""" + +from __future__ import annotations + +import inspect + +import pytest + +pytest.importorskip("copilot") + + +def test_copilot_client_symbols() -> None: + from copilot import CopilotClient, SubprocessConfig # noqa: F401 + + params = inspect.signature(CopilotClient.create_session).parameters + for expected in ( + "on_permission_request", + "model", + "session_id", + "working_directory", + "config_dir", + "skill_directories", + "available_tools", + "excluded_tools", + "on_event", + "system_message", + ): + assert expected in params, f"CopilotClient.create_session missing {expected}" + + +def test_session_send_and_wait_signature() -> None: + from copilot.session import CopilotSession + + params = inspect.signature(CopilotSession.send_and_wait).parameters + assert "prompt" in params + assert "timeout" in params + + +def test_permission_handler_present() -> None: + from copilot.session import PermissionHandler, PermissionRequestResult + + assert callable(PermissionHandler.approve_all) + # PermissionRequestResult must accept the kind= we use for denial. + denied = PermissionRequestResult(kind="denied", message="m") + assert denied.kind == "denied" + + +def test_session_event_type_values() -> None: + from copilot.generated.session_events import SessionEventType + + required = { + "SESSION_IDLE", + "SESSION_ERROR", + "ASSISTANT_MESSAGE", + "ASSISTANT_TURN_END", + "ASSISTANT_USAGE", + "TOOL_EXECUTION_START", + "TOOL_EXECUTION_COMPLETE", + "PERMISSION_REQUESTED", + "SESSION_SHUTDOWN", + } + have = {e.name for e in SessionEventType} + missing = required - have + assert not missing, f"SessionEventType missing values: {missing}" + + +def test_session_event_shape() -> None: + import dataclasses + + from copilot.generated.session_events import SessionEvent + + fields = {f.name for f in dataclasses.fields(SessionEvent)} + for expected in ("data", "id", "timestamp", "type"): + assert expected in fields, f"SessionEvent missing field {expected}" From 0291ca11b56671ee9afbd475d09ad482a84174e7 Mon Sep 17 00:00:00 2001 From: Lef Ioannidis Date: Mon, 20 Apr 2026 23:32:31 +0000 Subject: [PATCH 2/3] chore(examples/copilot_agent): post-merge cleanup Tidies six small warts surfaced during self-review. No behaviour change; all 21 unit tests still pass. - pyproject.toml: drop unused pydantic>=2.0 dependency - copilot_session.py: rename _deny_all_handler -> deny_all_handler so it can be imported without crossing the leading-underscore boundary; lift the nonlocal error_msg declaration out of the SESSION_ERROR branch to the top of _on_event and drop the incorrect noqa: PLW0603 (PLW0603 is the code for global-statement, not nonlocal); default final_event = None before the try block so the post-session read is well-typed even on an early error path - reasoners.py: update import + call sites for the public handler name - tests/test_copilot_session.py: drop unused monkeypatch fixture in test_ask_happy_path; follow handler rename - README.md: rewrite See-also to reference PR #491 rather than paths that only exist on the skillkit branch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/python_agent_nodes/copilot_agent/README.md | 9 +++++---- .../python_agent_nodes/copilot_agent/copilot_session.py | 7 ++++--- examples/python_agent_nodes/copilot_agent/pyproject.toml | 1 - examples/python_agent_nodes/copilot_agent/reasoners.py | 8 ++++---- .../copilot_agent/tests/test_copilot_session.py | 4 ++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/examples/python_agent_nodes/copilot_agent/README.md b/examples/python_agent_nodes/copilot_agent/README.md index 8e7f5454..5a884c64 100644 --- a/examples/python_agent_nodes/copilot_agent/README.md +++ b/examples/python_agent_nodes/copilot_agent/README.md @@ -125,8 +125,9 @@ loudly if the preview SDK drifts — pin `github-copilot-sdk==0.2.2` in ## See also -- `research/docs/2026-04-20-copilot-cli-support.md` — architecture overview. -- `control-plane/internal/skillkit/target_copilot.go` — how `af skill install` - ships skills into `~/.copilot/skills/` for Copilot to discover. +- **PR #491** ships `control-plane/internal/skillkit/target_copilot.go` — + the `af skill install` target that places AgentField skill packets into + `~/.copilot/skills/` for Copilot CLI to auto-discover. Merge that PR to + make the skill-integration half of this story fully end-to-end. - `af doctor --json | jq .copilot` — inspect Copilot auth/config on the - local machine. + local machine (also shipped in PR #491). diff --git a/examples/python_agent_nodes/copilot_agent/copilot_session.py b/examples/python_agent_nodes/copilot_agent/copilot_session.py index fcc6db01..41fdc38a 100644 --- a/examples/python_agent_nodes/copilot_agent/copilot_session.py +++ b/examples/python_agent_nodes/copilot_agent/copilot_session.py @@ -36,7 +36,7 @@ ) -def _deny_all_handler(request: Any, invocation: dict[str, str]) -> PermissionRequestResult: +def deny_all_handler(request: Any, invocation: dict[str, str]) -> PermissionRequestResult: """Permission handler that denies every tool invocation. Used by reasoners that must not execute anything (``ask``, ``plan``). @@ -161,7 +161,7 @@ async def run_copilot( available_tools: Optional[list[str]] = None, excluded_tools: Optional[list[str]] = None, system_message: Optional[str] = None, - permission_handler: Any = _deny_all_handler, + permission_handler: Any = deny_all_handler, timeout: float = 120.0, ) -> CopilotRunResult: """Run a single Copilot turn and return a structured result. @@ -187,8 +187,10 @@ async def run_copilot( tool_calls: list[dict[str, Any]] = [] usage: dict[str, int] = {} error_msg: Optional[str] = None + final_event: Optional[SessionEvent] = None def _on_event(event: SessionEvent) -> None: + nonlocal error_msg et = event.type data = event.data if et == SessionEventType.ASSISTANT_MESSAGE: @@ -222,7 +224,6 @@ def _on_event(event: SessionEvent) -> None: if isinstance(v, int): usage[k] = usage.get(k, 0) + v elif et == SessionEventType.SESSION_ERROR: - nonlocal error_msg # noqa: PLW0603 error_msg = getattr(data, "message", None) or "unknown session error" async with CopilotClient(**client_kwargs) as client: diff --git a/examples/python_agent_nodes/copilot_agent/pyproject.toml b/examples/python_agent_nodes/copilot_agent/pyproject.toml index cdf98a4d..2aae26d3 100644 --- a/examples/python_agent_nodes/copilot_agent/pyproject.toml +++ b/examples/python_agent_nodes/copilot_agent/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ # exist in the PyPI build; we discriminate events via SessionEventType # (stable, schema-generated). "github-copilot-sdk==0.2.2", - "pydantic>=2.0", ] [project.optional-dependencies] diff --git a/examples/python_agent_nodes/copilot_agent/reasoners.py b/examples/python_agent_nodes/copilot_agent/reasoners.py index af0e1359..369b64f2 100644 --- a/examples/python_agent_nodes/copilot_agent/reasoners.py +++ b/examples/python_agent_nodes/copilot_agent/reasoners.py @@ -18,7 +18,7 @@ from copilot.session import PermissionHandler -from copilot_session import CopilotRunResult, run_copilot, _deny_all_handler +from copilot_session import CopilotRunResult, deny_all_handler, run_copilot _DEFAULT_MODEL = os.getenv("COPILOT_MODEL", "gpt-5") @@ -62,7 +62,7 @@ async def ask( isolate=isolate, continue_session=continue_session, available_tools=[], - permission_handler=_deny_all_handler, + permission_handler=deny_all_handler, timeout=timeout, _ctx=execution_context, ) @@ -88,7 +88,7 @@ async def plan( cwd=cwd, isolate=isolate, available_tools=[], - permission_handler=_deny_all_handler, + permission_handler=deny_all_handler, timeout=timeout, _ctx=execution_context, ) @@ -117,7 +117,7 @@ async def review( cwd=cwd, isolate=isolate, available_tools=[], - permission_handler=_deny_all_handler, + permission_handler=deny_all_handler, timeout=timeout, _ctx=execution_context, ) diff --git a/examples/python_agent_nodes/copilot_agent/tests/test_copilot_session.py b/examples/python_agent_nodes/copilot_agent/tests/test_copilot_session.py index 86490b07..61d17455 100644 --- a/examples/python_agent_nodes/copilot_agent/tests/test_copilot_session.py +++ b/examples/python_agent_nodes/copilot_agent/tests/test_copilot_session.py @@ -14,7 +14,7 @@ @pytest.mark.asyncio -async def test_ask_happy_path(fake_app, stub_copilot_client, monkeypatch): +async def test_ask_happy_path(fake_app, stub_copilot_client): import copilot_session as cs stub_copilot_client["events"] = [ @@ -38,7 +38,7 @@ async def test_ask_happy_path(fake_app, stub_copilot_client, monkeypatch): assert result.copilot_session_id # Permission handler defaults to the deny-all handler defined in the module. kwargs = stub_copilot_client["captured_session_kwargs"] - assert kwargs["on_permission_request"] is cs._deny_all_handler + assert kwargs["on_permission_request"] is cs.deny_all_handler assert kwargs["available_tools"] == [] assert stub_copilot_client["captured_prompt"] == "say hi" From 2f3b7d6f3833a57cdde0a11b2f38b55e9e7bae6d Mon Sep 17 00:00:00 2001 From: Lef Ioannidis Date: Mon, 20 Apr 2026 23:48:12 +0000 Subject: [PATCH 3/3] docs(changelog): add unreleased entry for Copilot CLI agent example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5869e64..1619f342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +## [Unreleased] + +### Added + +- feat(examples): GitHub Copilot CLI as an AgentField reasoner node. New + `examples/python_agent_nodes/copilot_agent/` exposes four reasoners + (`ask`, `plan`, `review`, `run_task`) backed by the official + `github-copilot-sdk==0.2.2`. Deny-by-default permission posture; + `run_task` requires explicit `allow_tools=True` and supports allow/deny + tool lists. AgentField session id is mapped to a Copilot session id via + session-scoped memory so `continue_session=True` resumes correctly. + Opt-in isolation via `AGENTFIELD_COPILOT_ISOLATE=1` uses a per-node + sandbox instead of the user's shared `~/.copilot/`. + ## [0.1.70] - 2026-04-20 ## [0.1.70-rc.3] - 2026-04-20