1004 lines
45 KiB
Python
1004 lines
45 KiB
Python
"""CopilotExecutor: run agents through the GitHub Copilot SDK (``github-copilot-sdk``).
|
|
|
|
Drives GitHub Copilot via :mod:`copilot` (the ``github-copilot-sdk`` package) —
|
|
one persistent :class:`copilot.CopilotClient` + :class:`copilot.CopilotSession`
|
|
per Omnigent conversation, created once and reused turn to turn. Each
|
|
``run_turn`` issues one ``session.send_and_wait`` and translates the session's
|
|
streamed :class:`copilot.SessionEvent` objects into ExecutorEvents:
|
|
assistant text deltas → :class:`TextChunk`, reasoning deltas →
|
|
:class:`ReasoningChunk`, tool execution → :class:`ToolCallRequest` /
|
|
:class:`ToolCallComplete`, completing on ``ASSISTANT_TURN_END`` /
|
|
``SESSION_IDLE`` (when ``send_and_wait`` returns).
|
|
|
|
Crucially, Omnigent's spec-declared tools (``sys_session_send`` et al.) are
|
|
bridged into Copilot **in-process** via the SDK's ``tools``: each
|
|
:class:`~omnigent.inner.executor.ToolSpec` becomes a :class:`copilot.Tool`
|
|
whose async ``handler`` routes back to the executor's ``_tool_executor`` — the
|
|
same pattern the claude-sdk / cursor harnesses use. So a Copilot agent can call
|
|
``sys_*``, orchestrate sub-agents, and respect policies, i.e. full first-party
|
|
parity. Unlike cursor's daemon-thread bridge, the Copilot SDK awaits tool
|
|
handlers *in its own event loop* (``CopilotSession._execute_tool_and_respond``),
|
|
so the bridged handler is a plain coroutine that ``await``\\s ``_tool_executor``
|
|
directly — no ``run_coroutine_threadsafe`` hop.
|
|
|
|
Native tools — Copilot's built-in ``create`` / ``view`` / ``edit`` / ``bash`` run
|
|
*inside* the SDK rather than through Omnigent's bridged-tool dispatch. They are
|
|
gated through a two-stage check inside the SDK ``on_permission_request`` handler
|
|
(:meth:`CopilotExecutor._on_permission_request`, parity with the cursor harness):
|
|
|
|
1. **Policy hard-deny**: if the policy evaluator returns ``POLICY_ACTION_DENY``
|
|
the call is rejected immediately (the model sees the denial and continues,
|
|
rather than aborting the whole turn).
|
|
2. **User elicitation**: for any other outcome (ALLOW, ASK, or no evaluator
|
|
wired), ``_elicitation_handler`` is invoked so the user can approve or
|
|
reject from the web-UI approval card. If no handler is wired (single-process
|
|
/ pre-turn paths) the call is approved, preserving prior behavior.
|
|
|
|
Native tool calls still leave no ``function_call`` item in the persisted
|
|
transcript — only the streamed narration is recorded — so an ``on:[tool_call]``
|
|
guardrail won't *see* them as items, but the policy IS evaluated for them at
|
|
permission time. Bridged ``sys_*`` tools are gated + recorded server-side via
|
|
``_tool_executor`` (and registered ``skip_permission=True``, so they never reach
|
|
the permission handler).
|
|
|
|
Auth: a **GitHub token** that carries Copilot access — a fine-grained PAT with
|
|
the "Copilot Requests" permission, or an OAuth token from the GitHub CLI (``gh``)
|
|
/ Copilot CLI app. Resolved from a spec ``api_key`` or the ambient
|
|
``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN`` (the same precedence
|
|
the bundled CLI uses). Classic ``ghp_`` tokens are not accepted by Copilot.
|
|
|
|
Requirements:
|
|
The ``github-copilot-sdk`` package must be installed (it bundles the
|
|
Copilot CLI binary it drives as a backing server). Installed via the
|
|
``copilot`` extra; imported lazily so a missing install surfaces as a
|
|
request-time error, not an app-boot crash.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, TypeAlias
|
|
|
|
from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict
|
|
from omnigent.reasoning_effort import COPILOT_EFFORTS, validate_effort
|
|
|
|
from .datamodel import OSEnvSpec
|
|
from .executor import (
|
|
CompactionComplete,
|
|
Executor,
|
|
ExecutorConfig,
|
|
ExecutorError,
|
|
ExecutorEvent,
|
|
Message,
|
|
ReasoningChunk,
|
|
TextChunk,
|
|
ToolCallComplete,
|
|
ToolCallRequest,
|
|
ToolCallStatus,
|
|
ToolSpec,
|
|
TurnComplete,
|
|
classify_tool_result,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Omnigent's bridged-tool callback: (tool_name, args) -> awaitable result.
|
|
# Installed by the runtime adapter (see ``_executor_adapter``); mirrors the
|
|
# claude-sdk / cursor executors' ``ToolExecutor``.
|
|
ToolExecutor: TypeAlias = Callable[[str, dict[str, Any]], Awaitable[Any]] # type: ignore[explicit-any]
|
|
|
|
# Ambient GitHub-token env vars, in the precedence the Copilot CLI/SDK itself
|
|
# honors (``copilot login --help``): a fine-grained PAT with the "Copilot
|
|
# Requests" permission, or an OAuth token from the gh / Copilot CLI app.
|
|
GITHUB_TOKEN_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
|
|
|
# Upper bound (seconds) on one ``send_and_wait``. The SDK default is 60s, far
|
|
# too short for an agentic turn (sub-agent dispatches, long tool calls), so we
|
|
# pass a generous finite bound: a wedged turn surfaces a timeout error instead
|
|
# of hanging forever. The runner enforces its own per-turn timeout on top.
|
|
_SEND_TIMEOUT_S = 3600.0
|
|
|
|
|
|
def _resolve_model(model: str | None) -> str | None:
|
|
"""Resolve the Copilot model id, dropping ids Copilot can't honor.
|
|
|
|
The Copilot SDK accepts only ids in the account's catalog (``auto``,
|
|
``claude-haiku-4.5``, ``gpt-5-mini``, ...), so a gateway-routed model id
|
|
(carried by a spec authored for another harness) falls back to Copilot's
|
|
own auto-select (``None`` → the SDK picks). ``None`` likewise lets the SDK
|
|
choose.
|
|
"""
|
|
if not model or model.startswith(("databricks-", "databricks/")):
|
|
if model:
|
|
# Warn, not debug: the requested model is silently NOT honored, and
|
|
# a debug line is invisible in the harness subprocess — so a user who
|
|
# pinned a non-Copilot model would otherwise have no idea it was dropped.
|
|
logger.warning(
|
|
"CopilotExecutor: requested model %r is not a Copilot model id; "
|
|
"falling back to Copilot's auto-select.",
|
|
model,
|
|
)
|
|
return None
|
|
return model
|
|
|
|
|
|
def _resolve_reasoning_effort(config: ExecutorConfig | None) -> str | None:
|
|
"""Resolve the per-turn Copilot reasoning effort from ``config.extra``.
|
|
|
|
The runtime adapter threads a web ``/reasoning`` pick into
|
|
``config.extra["reasoning_effort"]`` (see
|
|
:class:`~omnigent.runtime.harnesses._executor_adapter.ExecutorAdapter`).
|
|
The Copilot SDK exposes it only as ``create_session(reasoning_effort=...)``,
|
|
so like the model it is fixed at session creation (a change recreates the
|
|
session in :meth:`run_turn`). ``None`` lets the model use its default.
|
|
|
|
A value Copilot can't honor is dropped with a warning rather than raising:
|
|
an unsupported effort must not sink the whole turn (parity with the codex
|
|
native path). Per-model support is enforced by the Copilot backend.
|
|
|
|
Note: ``config.extra`` may also carry ``max_tokens`` (set by the adapter),
|
|
but the Copilot SDK has no per-turn output-token cap — the only
|
|
``max_output_tokens`` lever is a model *capability* override folded into
|
|
context-window math, not a generation limit — so it is intentionally not
|
|
forwarded here.
|
|
"""
|
|
if config is None:
|
|
return None
|
|
raw_effort = config.extra.get("reasoning_effort")
|
|
try:
|
|
return validate_effort(raw_effort, "copilot", COPILOT_EFFORTS)
|
|
except ValueError:
|
|
logger.warning("Ignoring unsupported copilot reasoning effort: %r", raw_effort)
|
|
return None
|
|
|
|
|
|
def _tools_fingerprint(tools: list[ToolSpec]) -> str:
|
|
"""A stable fingerprint of the tool set (names + parameter schemas).
|
|
|
|
``tools`` are fixed at session creation, so a changed tool set must
|
|
invalidate the persistent session — otherwise removed tools stay callable
|
|
and newly-added tools are missing for the rest of the conversation.
|
|
"""
|
|
entries = sorted(
|
|
(str(t.get("name", "")), json.dumps(t.get("parameters"), sort_keys=True, default=str))
|
|
for t in tools
|
|
)
|
|
return json.dumps(entries)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompt building
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _extract_text(msg: Message) -> str:
|
|
"""Extract plain text content from a message dict."""
|
|
content = msg.get("content")
|
|
if content is None:
|
|
return ""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for part in content:
|
|
if isinstance(part, dict):
|
|
text = part.get("text")
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
return " ".join(parts)
|
|
return str(content)
|
|
|
|
|
|
def _latest_user_text(messages: list[Message]) -> str:
|
|
"""Return the text of the latest user message (multimodal parts joined)."""
|
|
for msg in reversed(messages):
|
|
if msg.get("role") == "user":
|
|
return _extract_text(msg)
|
|
return ""
|
|
|
|
|
|
def _build_copilot_prompt(messages: list[Message], *, is_first_turn: bool) -> str:
|
|
"""Build the prompt text for a ``send_and_wait``.
|
|
|
|
The SDK session persists conversation history across ``send`` calls and the
|
|
Omnigent system prompt is delivered separately (``system_message``), so on
|
|
the first turn any prior history (e.g. a ``pass_history=True`` sub-agent that
|
|
handed a single user message plus assistant / tool context) is serialized for
|
|
context. On subsequent turns the session already holds the history, so only
|
|
the latest user message is sent.
|
|
|
|
:returns: The prompt string (empty when there is nothing to send).
|
|
"""
|
|
if is_first_turn and len(messages) > 1:
|
|
lines = ["Conversation so far:"]
|
|
for msg in messages:
|
|
role = str(msg.get("role") or "user").replace("_", " ")
|
|
lines.append(f"{role}: {_extract_text(msg)}")
|
|
lines.append("")
|
|
lines.append(
|
|
"Respond to the latest user message, using the conversation above as context."
|
|
)
|
|
return "\n".join(lines)
|
|
return _latest_user_text(messages)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bridged-tool result encoding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _encode_tool_result(result: Any) -> Any: # type: ignore[explicit-any]
|
|
"""Encode a bridged-tool result as a :class:`copilot.ToolResult`.
|
|
|
|
A dict carrying a truthy ``error`` or ``blocked`` is a dispatch failure or a
|
|
policy block (the shapes ``_bridge_one_dispatch`` / the policy layer return):
|
|
surface it as a ``failure`` result so the Copilot model sees the failure —
|
|
parity with the claude-sdk / cursor handlers. Everything else is a success:
|
|
a ``str`` is passed through; anything else is JSON-encoded.
|
|
"""
|
|
from copilot import ToolResult # lazy: optional dependency
|
|
|
|
if isinstance(result, dict) and (result.get("error") or result.get("blocked")):
|
|
text = json.dumps(result, default=str)
|
|
return ToolResult(text_result_for_llm=text, result_type="failure", error=text)
|
|
if isinstance(result, str):
|
|
return ToolResult(text_result_for_llm=result)
|
|
try:
|
|
text = json.dumps(result, default=str)
|
|
except (TypeError, ValueError):
|
|
text = str(result)
|
|
return ToolResult(text_result_for_llm=text)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CopilotExecutor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class _CopilotSessionState:
|
|
"""Per-Omnigent-conversation SDK session state."""
|
|
|
|
client: Any = None # copilot.CopilotClient
|
|
session: Any = None # copilot.CopilotSession
|
|
system_prompt: str | None = None
|
|
model: str | None = None
|
|
reasoning_effort: str | None = None
|
|
tools_fingerprint: str | None = None
|
|
has_sent_prompt: bool = False
|
|
# call_id -> tool name, populated on TOOL_EXECUTION_START so the matching
|
|
# TOOL_EXECUTION_COMPLETE (which carries only the id) can name its tool.
|
|
call_names: dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
class CopilotExecutor(Executor):
|
|
"""Execute agent turns via a persistent GitHub Copilot SDK session."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
cwd: str | None = None,
|
|
os_env: OSEnvSpec | None = None,
|
|
model: str | None = None,
|
|
github_token: str | None = None,
|
|
bundle_dir: Path | None = None,
|
|
agent_name: str | None = None,
|
|
skills_filter: str | list[str] = "all",
|
|
) -> None:
|
|
"""Create a CopilotExecutor.
|
|
|
|
:param cwd: Working directory the Copilot session operates in. ``None``
|
|
falls back to ``os_env.cwd`` then the process cwd.
|
|
:param os_env: Optional OS environment / sandbox spec (its ``cwd`` is
|
|
used when *cwd* is unset).
|
|
:param model: Copilot model id (e.g. ``"claude-haiku-4.5"``); a
|
|
gateway-routed id or ``None`` falls back to Copilot's auto-select.
|
|
:param github_token: GitHub token carrying Copilot access. ``None``
|
|
falls back to ``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` /
|
|
``GITHUB_TOKEN`` in the environment.
|
|
:param bundle_dir: Reserved for future skill wiring; unused in v1.
|
|
:param agent_name: Optional agent name (reserved for parity).
|
|
:param skills_filter: Accepted for parity; copilot has no skill
|
|
mechanism wired here.
|
|
"""
|
|
self._cwd = cwd or (os_env.cwd if os_env is not None else None)
|
|
self._os_env_spec = os_env
|
|
self._model_override = model
|
|
self._github_token = github_token or _ambient_github_token()
|
|
self._bundle_dir = bundle_dir
|
|
self._agent_name = agent_name
|
|
self._skills_filter = skills_filter
|
|
self._session_states: dict[str, _CopilotSessionState] = {}
|
|
# Installed by the runtime adapter; routes a bridged-tool call back into
|
|
# Omnigent's session (policy gating, sub-agent dispatch, logging).
|
|
self._tool_executor: ToolExecutor | None = None
|
|
# Installed by the runtime adapter; evaluates PHASE_LLM_REQUEST /
|
|
# PHASE_LLM_RESPONSE policies (the same round-trip pi / claude-sdk /
|
|
# cursor use). ``None`` on single-process / pre-turn paths (no-op).
|
|
self._policy_evaluator: Callable[[str, dict[str, Any]], Awaitable[Any]] | None = None
|
|
# Installed by the runtime adapter; surfaces native-tool calls to the
|
|
# user via the web-UI elicitation approval card. ``None`` when no
|
|
# handler is wired (single-process / test paths → default approve).
|
|
self._elicitation_handler: Callable[[str, dict[str, Any]], Awaitable[bool]] | None = None
|
|
|
|
def supports_streaming(self) -> bool:
|
|
return True
|
|
|
|
def supports_tool_calling(self) -> bool:
|
|
return True
|
|
|
|
def handles_tools_internally(self) -> bool:
|
|
# Bridged tools execute in-band via the SDK tool handler (which calls
|
|
# ``_tool_executor``), so the runtime adapter must NOT re-dispatch the
|
|
# observed tool events — same contract as claude-sdk / cursor.
|
|
return True
|
|
|
|
def supports_live_message_queue(self) -> bool:
|
|
# The SDK exposes no confirmed mid-turn steer wired here, so a message
|
|
# can't be injected into a running turn.
|
|
return False
|
|
|
|
def _session_key(self, messages: list[Message]) -> str:
|
|
if messages:
|
|
last = messages[-1]
|
|
if last.get("session_id"):
|
|
return str(last["session_id"])
|
|
meta = last.get("metadata", {})
|
|
if isinstance(meta, dict) and meta.get("session_id"):
|
|
return str(meta["session_id"])
|
|
return "__default__"
|
|
|
|
# -- tool bridge --------------------------------------------------------
|
|
|
|
def _make_tools(self, tools: list[ToolSpec]) -> list[Any]: # type: ignore[explicit-any]
|
|
"""Build the SDK ``tools`` list from Omnigent ToolSpecs.
|
|
|
|
Each tool's async ``handler`` is awaited by the SDK in its own event
|
|
loop, so it bridges straight into ``_tool_executor`` — no thread hop.
|
|
``skip_permission=True`` because Omnigent's policy layer (and the
|
|
``_tool_executor`` round-trip) already governs these tools; the SDK
|
|
permission prompt would be redundant and would stall a headless turn.
|
|
"""
|
|
from copilot import Tool # lazy: optional dependency
|
|
|
|
built: list[Any] = [] # type: ignore[explicit-any]
|
|
for spec in tools:
|
|
name = spec.get("name")
|
|
if not isinstance(name, str) or not name:
|
|
continue
|
|
params = spec.get("parameters")
|
|
built.append(
|
|
Tool(
|
|
name=name,
|
|
description=spec.get("description") or "",
|
|
handler=self._make_handler(name),
|
|
parameters=params
|
|
if isinstance(params, dict)
|
|
else {"type": "object", "properties": {}},
|
|
skip_permission=True,
|
|
)
|
|
)
|
|
return built
|
|
|
|
def _make_handler(self, tool_name: str) -> Callable[[Any], Awaitable[Any]]: # type: ignore[explicit-any]
|
|
"""Build an async ``handler`` that bridges a Copilot tool call to Omnigent.
|
|
|
|
Awaited by the SDK in its event loop, so it ``await``\\s the main-loop
|
|
``_tool_executor`` directly. Any exception becomes a tool *failure*
|
|
result rather than propagating into the SDK turn.
|
|
"""
|
|
|
|
async def handler(invocation: Any) -> Any: # type: ignore[explicit-any]
|
|
from copilot import ToolResult # lazy: optional dependency
|
|
|
|
if self._tool_executor is None:
|
|
return ToolResult(
|
|
text_result_for_llm=(
|
|
f"Tool {tool_name!r} is unavailable: no tool executor wired."
|
|
),
|
|
result_type="failure",
|
|
error="no tool executor wired",
|
|
)
|
|
raw_args = getattr(invocation, "arguments", None)
|
|
args = _coerce_args(raw_args)
|
|
try:
|
|
result = await self._tool_executor(tool_name, dict(args))
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 — surface as a tool failure
|
|
return ToolResult(
|
|
text_result_for_llm=f"Tool {tool_name!r} failed: {exc}",
|
|
result_type="failure",
|
|
error=str(exc),
|
|
)
|
|
return _encode_tool_result(result)
|
|
|
|
return handler
|
|
|
|
# -- session lifecycle --------------------------------------------------
|
|
|
|
async def _on_permission_request(self, request: Any, _invocation: dict[str, str]) -> Any: # type: ignore[explicit-any]
|
|
"""Gate a Copilot NATIVE-tool permission request through policy + elicitation.
|
|
|
|
Installed as ``create_session(on_permission_request=...)``. The SDK awaits
|
|
this in its own event loop, so it ``await``\\s ``_policy_evaluator`` and
|
|
``_elicitation_handler`` directly (no thread hop). Only Copilot's built-in
|
|
tools (``bash`` / ``edit`` / ``view`` / ``create`` / ``url``) reach here;
|
|
bridged ``sys_*`` tools are registered ``skip_permission=True`` and are
|
|
gated + recorded server-side via ``_tool_executor`` instead.
|
|
|
|
Two-stage gate (parity with the cursor harness):
|
|
|
|
1. **Policy hard-deny**: if the policy evaluator returns
|
|
``POLICY_ACTION_DENY``, reject immediately without prompting the user.
|
|
The model sees the denial and continues (individual call blocked, not
|
|
the whole turn).
|
|
2. **User elicitation**: for any other outcome (ALLOW, ASK, or no
|
|
evaluator wired), invoke ``_elicitation_handler`` so the user can
|
|
approve or reject from the web-UI card. When no handler is wired
|
|
(single-process / pre-turn paths) the call is approved by default,
|
|
preserving prior behavior.
|
|
"""
|
|
from copilot.rpc import ( # lazy: optional dependency
|
|
PermissionDecisionApproveOnce,
|
|
PermissionDecisionReject,
|
|
)
|
|
|
|
name, args = _permission_policy_input(request)
|
|
|
|
# Stage 1 — hard policy deny: block immediately, no elicitation.
|
|
evaluator = self._policy_evaluator
|
|
if evaluator is not None:
|
|
verdict = await evaluator("PHASE_TOOL_CALL", {"name": name, "arguments": args})
|
|
if getattr(verdict, "action", "") == "POLICY_ACTION_DENY":
|
|
reason = getattr(verdict, "reason", "") or "blocked by policy"
|
|
return PermissionDecisionReject(feedback=f"Denied by Omnigent policy: {reason}")
|
|
|
|
# Stage 2 — user elicitation: surface an approval card.
|
|
handler = self._elicitation_handler
|
|
if handler is not None:
|
|
approved = await handler(name, args)
|
|
if not approved:
|
|
return PermissionDecisionReject(feedback="Denied via Omnigent approval UI")
|
|
|
|
return PermissionDecisionApproveOnce()
|
|
|
|
async def _ensure_session(
|
|
self,
|
|
state: _CopilotSessionState,
|
|
model: str | None,
|
|
tools: list[ToolSpec],
|
|
system_prompt: str,
|
|
reasoning_effort: str | None = None,
|
|
) -> None:
|
|
"""Start the SDK client and create the session if not already live.
|
|
|
|
On any bring-up failure the partially-started client is stopped before
|
|
propagating, so a bad token / launch error can't orphan the bundled
|
|
CLI subprocess.
|
|
"""
|
|
if state.session is not None:
|
|
return
|
|
try:
|
|
from copilot import CopilotClient
|
|
except ImportError as exc:
|
|
from omnigent.onboarding.copilot_auth import COPILOT_EXTRA
|
|
from omnigent.onboarding.extra_install import extra_install_display
|
|
|
|
raise ImportError(
|
|
"CopilotExecutor requires the 'github-copilot-sdk' package. "
|
|
f"Install it with: {extra_install_display(COPILOT_EXTRA)}"
|
|
) from exc
|
|
|
|
# The Copilot SDK rejects a relative working_directory ("Directory path
|
|
# must be absolute"), and a spec / os_env can hand us a relative cwd
|
|
# (e.g. ``.``), so always resolve to an absolute path.
|
|
cwd = os.path.abspath(self._cwd or os.getcwd())
|
|
client = CopilotClient(
|
|
github_token=self._github_token,
|
|
working_directory=cwd,
|
|
log_level="error",
|
|
)
|
|
try:
|
|
# ``start()`` is inside the try: it spawns the bundled Copilot CLI
|
|
# subprocess *before* connecting/verifying, and the SDK's own error
|
|
# path re-raises without terminating that subprocess — only
|
|
# ``client.stop()`` reaps it. So a start failure (bad token, version
|
|
# skew) must still hit ``_safe_stop`` below, or it orphans the CLI.
|
|
await client.start()
|
|
# ``append`` keeps Copilot's own operating instructions (how to use
|
|
# its built-in tools) and layers the Omnigent agent's system prompt
|
|
# on top — a ``replace`` would strip the tool-use guidance the model
|
|
# relies on. ``None`` when the spec carries no system prompt.
|
|
system_message = (
|
|
{"mode": "append", "content": system_prompt} if system_prompt else None
|
|
)
|
|
session = await client.create_session(
|
|
model=model,
|
|
streaming=True,
|
|
system_message=system_message,
|
|
tools=self._make_tools(tools) or None,
|
|
on_permission_request=self._on_permission_request,
|
|
working_directory=cwd,
|
|
reasoning_effort=reasoning_effort or None,
|
|
)
|
|
except BaseException:
|
|
await _safe_stop(client)
|
|
raise
|
|
state.client = client
|
|
state.session = session
|
|
|
|
async def run_turn(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[ToolSpec],
|
|
system_prompt: str,
|
|
config: ExecutorConfig | None = None,
|
|
) -> AsyncIterator[ExecutorEvent]:
|
|
session_key = self._session_key(messages)
|
|
model = _resolve_model((config.model if config else None) or self._model_override)
|
|
reasoning_effort = _resolve_reasoning_effort(config)
|
|
tools_fp = _tools_fingerprint(tools)
|
|
state = self._session_states.setdefault(session_key, _CopilotSessionState())
|
|
|
|
# System prompt, model, reasoning effort, and tool set are all fixed at
|
|
# session creation, so a change to any of them means a fresh session
|
|
# (otherwise a changed tool set would leave the initial ``tools`` stale
|
|
# for the conversation, and a new ``/reasoning`` pick would never apply).
|
|
if state.session is not None and (
|
|
state.system_prompt != system_prompt
|
|
or state.model != model
|
|
or state.reasoning_effort != reasoning_effort
|
|
or state.tools_fingerprint != tools_fp
|
|
):
|
|
await self._close_state(state)
|
|
state = _CopilotSessionState()
|
|
self._session_states[session_key] = state
|
|
is_first_turn = not state.has_sent_prompt
|
|
state.system_prompt = system_prompt
|
|
state.model = model
|
|
state.reasoning_effort = reasoning_effort
|
|
state.tools_fingerprint = tools_fp
|
|
|
|
try:
|
|
await self._ensure_session(state, model, tools, system_prompt, reasoning_effort)
|
|
except Exception as exc: # noqa: BLE001 — surfaced as ExecutorError (CancelledError propagates)
|
|
await self.close_session(session_key)
|
|
yield ExecutorError(message=f"Failed to start copilot-sdk session: {exc}")
|
|
return
|
|
|
|
prompt = _build_copilot_prompt(messages, is_first_turn=is_first_turn)
|
|
if not prompt:
|
|
yield TurnComplete(response=None)
|
|
return
|
|
|
|
# PHASE_LLM_REQUEST policy (parity with claude-sdk / cursor / pi):
|
|
# evaluate before the LLM call so a DENY blocks it. No-op when unwired.
|
|
policy_eval = self._policy_evaluator
|
|
if policy_eval is not None:
|
|
req_verdict = await policy_eval(
|
|
"PHASE_LLM_REQUEST",
|
|
{
|
|
"model": model or "auto",
|
|
"messages_count": sum(1 for m in messages if m.get("role") == "user") or 1,
|
|
"tools_count": len(tools),
|
|
"system_prompt_preview": system_prompt[:200] if system_prompt else "",
|
|
"last_user_message": _latest_user_text(messages)[:500],
|
|
},
|
|
)
|
|
if getattr(req_verdict, "action", "") == "POLICY_ACTION_DENY":
|
|
reason = getattr(req_verdict, "reason", "") or "no reason given"
|
|
yield ExecutorError(message=f"LLM call denied by policy: {reason}")
|
|
return
|
|
|
|
state.has_sent_prompt = True
|
|
session = state.session
|
|
|
|
# Stream the session's events into a queue from the SDK's ``on`` callback
|
|
# (invoked in the SDK loop), then drain-and-translate them here while the
|
|
# ``send_and_wait`` task runs. Unsubscribe + drain when the send completes.
|
|
queue: asyncio.Queue[Any] = asyncio.Queue() # type: ignore[explicit-any]
|
|
|
|
def _on_event(event: Any) -> None: # type: ignore[explicit-any]
|
|
queue.put_nowait(event)
|
|
|
|
unsubscribe = session.on(_on_event)
|
|
send_task: asyncio.Task[Any] = asyncio.ensure_future( # type: ignore[explicit-any]
|
|
session.send_and_wait(prompt, timeout=_SEND_TIMEOUT_S)
|
|
)
|
|
|
|
response_text = ""
|
|
tool_calls = 0
|
|
usage_acc: dict[str, int] = {}
|
|
usage_model: str | None = None
|
|
turn_error: str | None = None
|
|
# A tool call between two assistant text blocks means they are distinct
|
|
# narration segments (pre- vs post-tool); insert a paragraph break so
|
|
# they don't render as one run-on string. Streamed deltas of a single
|
|
# response (no tool between) still concatenate seamlessly.
|
|
separate_next_text = False
|
|
|
|
async def _drain(event: Any) -> AsyncIterator[ExecutorEvent]: # type: ignore[explicit-any]
|
|
nonlocal response_text, tool_calls, separate_next_text, usage_model, turn_error
|
|
etype = str(getattr(event, "type", ""))
|
|
data = _event_data(event)
|
|
if etype.endswith("ASSISTANT_MESSAGE_DELTA"):
|
|
text = str(data.get("deltaContent") or "")
|
|
if text:
|
|
if separate_next_text and response_text:
|
|
trailing = len(response_text) - len(response_text.rstrip("\n"))
|
|
leading = len(text) - len(text.lstrip("\n"))
|
|
if trailing + leading < 2:
|
|
text = "\n" * (2 - trailing - leading) + text
|
|
separate_next_text = False
|
|
response_text += text
|
|
yield TextChunk(text=text)
|
|
elif etype.endswith("ASSISTANT_REASONING_DELTA"):
|
|
text = str(data.get("deltaContent") or "")
|
|
if text:
|
|
yield ReasoningChunk(delta=text, event_type="reasoning_text")
|
|
elif etype.endswith("TOOL_EXECUTION_START"):
|
|
name = str(data.get("toolName") or "tool")
|
|
call_id = data.get("toolCallId")
|
|
if call_id:
|
|
state.call_names[str(call_id)] = name
|
|
tool_calls += 1
|
|
separate_next_text = True
|
|
yield ToolCallRequest(
|
|
name=name,
|
|
args=_coerce_args(data.get("arguments")),
|
|
metadata={"call_id": call_id},
|
|
)
|
|
elif etype.endswith("TOOL_EXECUTION_COMPLETE"):
|
|
call_id = data.get("toolCallId")
|
|
name = state.call_names.get(str(call_id), "tool") if call_id else "tool"
|
|
result = _unwrap_tool_result(data.get("result"))
|
|
classification = classify_tool_result(result)
|
|
status = classification.status
|
|
error = classification.error or None
|
|
raw_error = data.get("error")
|
|
if data.get("success") is False or raw_error:
|
|
status = ToolCallStatus.ERROR
|
|
error = error or _unwrap_tool_error(raw_error) or "tool call failed"
|
|
separate_next_text = True
|
|
yield ToolCallComplete(
|
|
name=name,
|
|
status=status,
|
|
result=result,
|
|
error=error,
|
|
metadata={"call_id": call_id},
|
|
)
|
|
elif etype.endswith("ASSISTANT_USAGE"):
|
|
usage_model = data.get("model") or usage_model
|
|
_accumulate_usage(usage_acc, data)
|
|
elif etype.endswith(("SESSION_ERROR", "MODEL_CALL_FAILURE")):
|
|
turn_error = str(
|
|
data.get("message") or data.get("errorMessage") or "copilot session error"
|
|
)
|
|
elif etype.endswith("SESSION_COMPACTION_COMPLETE"):
|
|
# The Copilot SDK auto-compacted the session's context window.
|
|
# Surface it as a CompactionComplete so the runner persists a
|
|
# compaction item and a resumed session gets the pre-compacted
|
|
# summary instead of replaying the full transcript (parity with
|
|
# claude-sdk / openai-agents). Only a *successful* compaction
|
|
# counts; ``success`` is False on a failed/aborted attempt.
|
|
if data.get("success"):
|
|
post_tokens = data.get("postCompactionTokens")
|
|
yield CompactionComplete(
|
|
# Copilot uniquely reports the real summary text; fall
|
|
# back to a synthetic placeholder like the peers do.
|
|
summary=str(
|
|
data.get("summaryContent")
|
|
or "[GitHub Copilot compaction: context was automatically compacted]"
|
|
),
|
|
# Post-compaction context size (same semantic as the
|
|
# peers' ``context_tokens``).
|
|
token_count=(
|
|
int(post_tokens) if isinstance(post_tokens, (int, float)) else 0
|
|
),
|
|
model=usage_model or model,
|
|
# The event carries no message list; resume falls back to
|
|
# the summary (allowed by the field's contract).
|
|
compacted_messages=None,
|
|
)
|
|
|
|
try:
|
|
while True:
|
|
getter: asyncio.Task[Any] = asyncio.ensure_future(queue.get()) # type: ignore[explicit-any]
|
|
done, _pending = await asyncio.wait(
|
|
{getter, send_task}, return_when=asyncio.FIRST_COMPLETED
|
|
)
|
|
if getter in done:
|
|
event = getter.result()
|
|
async for ev in _drain(event):
|
|
yield ev
|
|
else:
|
|
getter.cancel()
|
|
if send_task.done():
|
|
# The turn ended — drain whatever is still queued, then stop.
|
|
while not queue.empty():
|
|
async for ev in _drain(queue.get_nowait()):
|
|
yield ev
|
|
break
|
|
final_event = send_task.result()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 — the SDK turn failed mid-stream
|
|
await self.close_session(session_key)
|
|
yield ExecutorError(message=f"copilot-sdk turn failed: {exc}", retryable=True)
|
|
return
|
|
finally:
|
|
unsubscribe()
|
|
if not send_task.done():
|
|
send_task.cancel()
|
|
|
|
# Surface a mid-turn error event (``SESSION_ERROR`` / ``MODEL_CALL_FAILURE``)
|
|
# whenever the turn did NOT produce a successful final message — even if
|
|
# some text streamed first. Reporting a failed turn as a clean
|
|
# ``TurnComplete`` with partial text would mask the failure (a turn that
|
|
# errored after partial output would look successful). A real final
|
|
# ASSISTANT_MESSAGE (``final_event`` set) means the SDK completed the turn,
|
|
# so a stray earlier event is not treated as fatal.
|
|
if turn_error and final_event is None:
|
|
await self.close_session(session_key)
|
|
yield ExecutorError(message=f"copilot-sdk run error: {turn_error}", retryable=True)
|
|
return
|
|
|
|
# Prefer the streamed text (which carries the paragraph breaks inserted
|
|
# above); fall back to the final ASSISTANT_MESSAGE content only when
|
|
# nothing streamed (e.g. a tool-only turn).
|
|
final = response_text or _final_message_text(final_event) or None
|
|
|
|
# PHASE_LLM_RESPONSE policy (parity with the peer harnesses).
|
|
if policy_eval is not None:
|
|
resp_verdict = await policy_eval(
|
|
"PHASE_LLM_RESPONSE",
|
|
{
|
|
"model": model or "auto",
|
|
"text_preview": response_text[:500] if response_text else "",
|
|
"tool_calls_count": tool_calls,
|
|
},
|
|
)
|
|
if getattr(resp_verdict, "action", "") == "POLICY_ACTION_DENY":
|
|
reason = getattr(resp_verdict, "reason", "") or "no reason given"
|
|
yield ExecutorError(message=f"LLM response denied by policy: {reason}")
|
|
return
|
|
|
|
usage = _finalize_usage(usage_acc)
|
|
if usage is not None:
|
|
_notify_usage_from_dict(model=usage_model or model or "copilot", usage=usage)
|
|
yield TurnComplete(response=final, usage=usage)
|
|
|
|
async def _close_state(self, state: _CopilotSessionState) -> None:
|
|
if state.session is not None:
|
|
await _safe_stop(state.session)
|
|
state.session = None
|
|
if state.client is not None:
|
|
await _safe_stop(state.client)
|
|
state.client = None
|
|
state.call_names.clear()
|
|
|
|
async def close_session(self, session_key: str) -> None:
|
|
state = self._session_states.pop(session_key, None)
|
|
if state is not None:
|
|
await self._close_state(state)
|
|
|
|
async def interrupt_session(self, session_key: str) -> bool:
|
|
state = self._session_states.get(session_key)
|
|
if state is None:
|
|
return False
|
|
# Best-effort SDK abort first, to cleanly cancel the in-flight turn on
|
|
# the bundled CLI *before* teardown. ``session.abort()`` is the SDK's
|
|
# blessed cancel (it keeps the session valid), so it stops the next
|
|
# ``stop()`` from racing a live generation — which can orphan the CLI's
|
|
# tool subprocesses or dump a post-cancel stream. Mirrors pi / claude-sdk.
|
|
if state.session is not None:
|
|
try:
|
|
await asyncio.wait_for(state.session.abort(), timeout=0.5)
|
|
except Exception as exc: # noqa: BLE001 — abort is best-effort
|
|
logger.debug("CopilotExecutor: interrupt abort failed: %s", exc)
|
|
# Always drop the session so the next turn starts a fresh one — mirrors
|
|
# the cursor / pi executors. A resumed Copilot session sends only the
|
|
# latest user message (see ``_build_copilot_prompt``), which would bypass
|
|
# the runner's "[System: interrupted]" marker and silently continue the
|
|
# abandoned request; a fresh session replays full history (marker
|
|
# included). See ``claude_sdk_executor.interrupt_session`` for the rationale.
|
|
try:
|
|
await self.close_session(session_key)
|
|
return True
|
|
except Exception as exc: # noqa: BLE001 — close failures surface as False
|
|
logger.debug("CopilotExecutor: close after interrupt failed: %s", exc)
|
|
return False
|
|
|
|
async def close(self) -> None:
|
|
for key in list(self._session_states.keys()):
|
|
await self.close_session(key)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ambient_github_token() -> str | None:
|
|
"""Return the first set ambient GitHub token, in CLI precedence order."""
|
|
for var in GITHUB_TOKEN_ENV_VARS:
|
|
value = os.environ.get(var)
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _coerce_args(raw: Any) -> dict[str, Any]: # type: ignore[explicit-any]
|
|
"""Coerce a tool-call ``arguments`` payload to a dict.
|
|
|
|
The SDK delivers arguments as a parsed dict, but tolerate a JSON string (or
|
|
anything else) by best-effort parsing, falling back to an empty mapping.
|
|
"""
|
|
if isinstance(raw, dict):
|
|
return raw
|
|
if isinstance(raw, str) and raw.strip():
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
return {}
|
|
|
|
|
|
def _permission_policy_input(request: Any) -> tuple[str, dict[str, Any]]: # type: ignore[explicit-any]
|
|
"""Map a Copilot ``PermissionRequest`` variant to a (name, arguments) policy input.
|
|
|
|
The permission-request union is non-uniform: only the ``mcp`` / ``custom-tool``
|
|
/ ``hook`` variants carry ``tool_name``, while ``shell`` / ``read`` / ``write``
|
|
/ ``url`` identify themselves by their ``kind`` discriminator. Fall back to
|
|
``kind`` for the name, and pass the variant's ``to_dict()`` (camelCase wire
|
|
form, carrying all fields) as the policy ``arguments``.
|
|
"""
|
|
name = getattr(request, "tool_name", None) or getattr(request, "kind", None) or "tool"
|
|
try:
|
|
args = request.to_dict()
|
|
except Exception: # noqa: BLE001 — defensive: hand the policy a best-effort dict
|
|
args = {}
|
|
return str(name), args if isinstance(args, dict) else {}
|
|
|
|
|
|
def _event_data(event: Any) -> dict[str, Any]: # type: ignore[explicit-any]
|
|
"""Return an event's ``data`` payload as a (camelCase-keyed) dict.
|
|
|
|
Uses ``to_dict()`` (the wire form) so reads are schema-stable across the
|
|
SDK's snake_case attributes vs. camelCase wire keys.
|
|
"""
|
|
try:
|
|
payload = event.to_dict()
|
|
except Exception: # noqa: BLE001 — fall back to attribute access below
|
|
payload = None
|
|
if isinstance(payload, dict):
|
|
data = payload.get("data")
|
|
if isinstance(data, dict):
|
|
return data
|
|
data_attr = getattr(event, "data", None)
|
|
if isinstance(data_attr, dict):
|
|
return data_attr
|
|
return {}
|
|
|
|
|
|
# SDK ``ToolExecutionCompleteResult.to_dict`` wraps the payload as
|
|
# ``{"content": ..., "contents": [...], "detailedContent": ..., "uiResource": ...}``
|
|
# and ``ToolExecutionCompleteError.to_dict`` as ``{"message": ..., "code": ...}``.
|
|
_TOOL_RESULT_WRAPPER_KEYS = ("content", "contents", "detailedContent", "uiResource")
|
|
|
|
|
|
def _unwrap_tool_result(raw: Any) -> Any: # type: ignore[explicit-any]
|
|
"""Unwrap the SDK ``ToolExecutionCompleteResult`` wrapper to its content payload.
|
|
|
|
A ``TOOL_EXECUTION_COMPLETE`` ``result`` arrives in wire form as a wrapper
|
|
dict (``{"content": ..., "detailedContent": ..., ...}``). Carry the bare
|
|
content downstream (classification, tracing, the persisted ``ToolCallComplete``
|
|
result) rather than the wrapper — matching the bare-payload convention the
|
|
peer executors use. Only a recognized wrapper shape is unwrapped, so an
|
|
Omnigent-convention result (e.g. ``{"blocked": True}``) passes through
|
|
untouched for :func:`classify_tool_result`.
|
|
"""
|
|
if isinstance(raw, dict) and any(k in raw for k in _TOOL_RESULT_WRAPPER_KEYS):
|
|
for key in ("content", "detailedContent"):
|
|
value = raw.get(key)
|
|
if value is not None:
|
|
return value
|
|
return raw
|
|
|
|
|
|
def _unwrap_tool_error(raw: Any) -> str | None: # type: ignore[explicit-any]
|
|
"""Extract the message from the SDK's structured tool error.
|
|
|
|
A failed ``TOOL_EXECUTION_COMPLETE`` ``error`` arrives as
|
|
``{"message": ..., "code": ...}`` (``ToolExecutionCompleteError.to_dict``);
|
|
surface the ``message`` rather than the dict's Python repr (which would leak
|
|
``"{'message': ..., 'code': ...}"`` into the tool error shown to the
|
|
model / recorded on the span). A bare string passes through; anything else
|
|
yields ``None`` so the caller can fall back to a generic message.
|
|
"""
|
|
if isinstance(raw, dict):
|
|
message = raw.get("message")
|
|
if isinstance(message, str) and message:
|
|
return message
|
|
if isinstance(raw, str) and raw:
|
|
return raw
|
|
return None
|
|
|
|
|
|
def _final_message_text(event: Any) -> str: # type: ignore[explicit-any]
|
|
"""Extract the aggregate assistant text from the final ASSISTANT_MESSAGE event."""
|
|
if event is None:
|
|
return ""
|
|
content = _event_data(event).get("content")
|
|
return content if isinstance(content, str) else ""
|
|
|
|
|
|
def _accumulate_usage(acc: dict[str, int], data: dict[str, Any]) -> None: # type: ignore[explicit-any]
|
|
"""Sum the token counts from one ASSISTANT_USAGE event into *acc*.
|
|
|
|
Copilot emits one usage event per underlying model call, so a turn with
|
|
tool round-trips reports usage several times; we accumulate. The
|
|
authoritative AI-credit cost (``copilotUsage.totalNanoAiu``, in nano-AIU) is
|
|
summed too and converted to ``cost_usd`` in :func:`_finalize_usage`.
|
|
"""
|
|
mapping = {
|
|
"inputTokens": "input_tokens",
|
|
"outputTokens": "output_tokens",
|
|
"cacheReadTokens": "cache_read_input_tokens",
|
|
"cacheWriteTokens": "cache_creation_input_tokens",
|
|
}
|
|
for wire_key, usage_key in mapping.items():
|
|
value = data.get(wire_key)
|
|
if isinstance(value, (int, float)):
|
|
acc[usage_key] = acc.get(usage_key, 0) + int(value)
|
|
copilot_usage = data.get("copilotUsage")
|
|
if isinstance(copilot_usage, dict):
|
|
nano_aiu = copilot_usage.get("totalNanoAiu")
|
|
if isinstance(nano_aiu, (int, float)):
|
|
acc["_cost_nano_aiu"] = acc.get("_cost_nano_aiu", 0) + int(nano_aiu)
|
|
|
|
|
|
def _finalize_usage(acc: dict[str, int]) -> dict[str, Any] | None: # type: ignore[explicit-any]
|
|
"""Build the TurnComplete usage dict from accumulated counts, or ``None``."""
|
|
if not acc:
|
|
return None
|
|
usage: dict[str, Any] = dict(acc) # type: ignore[explicit-any]
|
|
nano_aiu = usage.pop("_cost_nano_aiu", 0)
|
|
usage["total_tokens"] = usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
|
|
if nano_aiu:
|
|
# 1 AIC = 1e9 nano-AIU = $0.01, so nano-AIU / 1e11 = USD.
|
|
usage["cost_usd"] = nano_aiu / 1e11
|
|
return usage
|
|
|
|
|
|
async def _safe_stop(obj: Any) -> None: # type: ignore[explicit-any]
|
|
"""Best-effort async teardown of a ``copilot`` client / session.
|
|
|
|
:class:`copilot.CopilotClient` exposes ``stop()`` (terminates the bundled
|
|
CLI subprocess it launched); :class:`copilot.CopilotSession` exposes
|
|
``disconnect()``. Prefer ``stop`` then ``disconnect``; a teardown failure
|
|
must not mask the original error or leave the closer raising.
|
|
"""
|
|
closer = (
|
|
getattr(obj, "stop", None)
|
|
or getattr(obj, "disconnect", None)
|
|
or getattr(obj, "aclose", None)
|
|
or getattr(obj, "close", None)
|
|
)
|
|
if closer is None:
|
|
return
|
|
try:
|
|
result = closer()
|
|
if asyncio.iscoroutine(result):
|
|
await result
|
|
except Exception as exc: # noqa: BLE001 — best-effort teardown
|
|
logger.debug("CopilotExecutor: stop failed: %s", exc)
|