agent-framework-github-copilot 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1364 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import inspect
8
+ import logging
9
+ import sys
10
+ import warnings
11
+ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
12
+ from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload
13
+
14
+ from agent_framework import (
15
+ AgentMiddlewareLayer,
16
+ AgentMiddlewareTypes,
17
+ AgentResponse,
18
+ AgentResponseUpdate,
19
+ AgentSession,
20
+ BaseAgent,
21
+ Content,
22
+ ContextProvider,
23
+ HistoryProvider,
24
+ Message,
25
+ ResponseStream,
26
+ SessionContext,
27
+ UsageDetails,
28
+ add_usage_details,
29
+ normalize_messages,
30
+ )
31
+ from agent_framework._settings import load_settings
32
+ from agent_framework._tools import FunctionTool, ToolTypes
33
+ from agent_framework._types import (
34
+ AgentRunInputs,
35
+ _get_data_bytes_as_str, # pyright: ignore[reportPrivateUsage]
36
+ normalize_tools,
37
+ )
38
+ from agent_framework.exceptions import AgentException, ContentError
39
+ from agent_framework.observability import AgentTelemetryLayer
40
+
41
+ if sys.version_info >= (3, 11):
42
+ from typing import Self # pragma: no cover
43
+ else:
44
+ from typing_extensions import Self # pragma: no cover
45
+ if sys.version_info >= (3, 13):
46
+ from typing import TypeVar # pragma: no cover
47
+ else:
48
+ from typing_extensions import TypeVar # pragma: no cover
49
+
50
+ try:
51
+ from copilot import CopilotClient, CopilotSession, RuntimeConnection
52
+ from copilot.generated.rpc import PermissionDecisionUserNotAvailable
53
+ from copilot.session import (
54
+ Attachment,
55
+ BlobAttachment,
56
+ MCPServerConfig,
57
+ PermissionRequestResult,
58
+ PreToolUseHandler,
59
+ PreToolUseHookOutput,
60
+ ProviderConfig,
61
+ SessionHooks,
62
+ SystemMessageConfig,
63
+ )
64
+ from copilot.session_events import AssistantUsageData, PermissionRequest, SessionEvent, SessionEventType
65
+ from copilot.tools import Tool as CopilotTool
66
+ from copilot.tools import ToolInvocation, ToolResult
67
+ except ImportError as _copilot_import_error:
68
+ raise ImportError(
69
+ "GitHubCopilotAgent requires the 'github-copilot-sdk' package, which is only available on Python 3.11+. "
70
+ "Please use Python 3.11 or later."
71
+ ) from _copilot_import_error
72
+
73
+ DEFAULT_TIMEOUT_SECONDS: float = 60.0
74
+ """Default timeout in seconds for Copilot requests."""
75
+
76
+ PermissionHandlerType = Callable[
77
+ [PermissionRequest, dict[str, str]], "PermissionRequestResult | Awaitable[PermissionRequestResult]"
78
+ ]
79
+ """Type for permission request handlers. Supports both sync and async callbacks."""
80
+
81
+
82
+ FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
83
+ """Deprecated approval callback for ``FunctionTool`` instances declared with
84
+ ``approval_mode="always_require"``.
85
+
86
+ .. deprecated::
87
+ Use the SDK ``on_pre_tool_use`` hook together with ``on_permission_request``
88
+ instead. The default ``on_pre_tool_use`` hook returns ``"ask"`` for
89
+ ``always_require`` tools and routes the decision to ``on_permission_request``.
90
+
91
+ The callback receives a ``FunctionCallContent`` describing the pending call
92
+ (``name``, ``arguments``, and a synthetic ``call_id``) and must return ``True``
93
+ to allow execution or ``False`` to deny it. Both synchronous and ``await``-able
94
+ return values are supported.
95
+ """
96
+
97
+
98
+ async def _resolve_function_approval(
99
+ callback: FunctionApprovalCallback | None,
100
+ func_tool: FunctionTool,
101
+ arguments: Mapping[str, Any] | None,
102
+ ) -> bool:
103
+ """Run the deprecated agent-level approval callback for a pending tool call.
104
+
105
+ Returns ``True`` only when ``callback`` is configured and explicitly returns
106
+ a truthy value. A missing callback or any callback failure is treated as a
107
+ denial so the secure-by-default policy holds even if the user code raises.
108
+ """
109
+ if callback is None:
110
+ return False
111
+ request = Content.from_function_call(
112
+ call_id=f"af-copilot-approval::{func_tool.name}",
113
+ name=func_tool.name,
114
+ arguments=None if arguments is None else dict(arguments),
115
+ )
116
+ try:
117
+ outcome = callback(request)
118
+ if inspect.isawaitable(outcome):
119
+ outcome = await outcome
120
+ except Exception:
121
+ logger.exception(
122
+ "on_function_approval callback raised for tool '%s'; denying execution.",
123
+ func_tool.name,
124
+ )
125
+ return False
126
+ return bool(outcome)
127
+
128
+
129
+ logger = logging.getLogger("agent_framework.github_copilot")
130
+
131
+
132
+ def _deny_all_permissions(
133
+ _request: PermissionRequest,
134
+ _invocation: dict[str, str],
135
+ ) -> PermissionRequestResult:
136
+ """Default permission handler that denies all requests."""
137
+ return PermissionDecisionUserNotAvailable()
138
+
139
+
140
+ class GitHubCopilotSettings(TypedDict, total=False):
141
+ """GitHub Copilot model settings.
142
+
143
+ Settings are resolved in this order: explicit keyword arguments, values from an
144
+ explicitly provided .env file, then environment variables with the prefix
145
+ 'GITHUB_COPILOT_'.
146
+
147
+ Keys:
148
+ cli_path: Path to the Copilot CLI executable.
149
+ Can be set via environment variable GITHUB_COPILOT_CLI_PATH.
150
+ model: Model to use (e.g., "gpt-5", "claude-sonnet-4").
151
+ Can be set via environment variable GITHUB_COPILOT_MODEL.
152
+ timeout: Request timeout in seconds.
153
+ Can be set via environment variable GITHUB_COPILOT_TIMEOUT.
154
+ log_level: CLI log level.
155
+ Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL.
156
+ base_directory: Directory where the CLI stores session state, configuration,
157
+ and other persistent data. Can be set via environment variable
158
+ GITHUB_COPILOT_BASE_DIRECTORY. Defaults to ~/.copilot when not set.
159
+ Only applicable when the SDK spawns the CLI process (ignored when
160
+ connecting to an external server via a pre-configured client).
161
+ """
162
+
163
+ cli_path: str | None
164
+ model: str | None
165
+ timeout: float | None
166
+ log_level: str | None
167
+ base_directory: str | None
168
+
169
+
170
+ class GitHubCopilotOptions(TypedDict, total=False):
171
+ """GitHub Copilot-specific options.
172
+
173
+ The keys below have first-class typing and inline documentation because they are
174
+ the commonly used options. They are **not** an exhaustive list: any other
175
+ parameter accepted by the Copilot SDK's ``create_session`` (for example
176
+ ``reasoning_effort``, ``context_tier``, ``enable_citations``, ``available_tools``,
177
+ ``memory``, ...) may also be supplied and is forwarded verbatim to the SDK. An
178
+ unrecognized parameter name surfaces as a ``TypeError`` from the SDK, so typos are
179
+ caught rather than silently ignored.
180
+ """
181
+
182
+ system_message: SystemMessageConfig
183
+ """System message configuration for the session. Use mode 'append' to add to the default
184
+ system prompt, or 'replace' to completely override it."""
185
+
186
+ cli_path: str
187
+ """Path to the Copilot CLI executable. Defaults to GITHUB_COPILOT_CLI_PATH environment variable
188
+ or 'copilot' in PATH."""
189
+
190
+ model: str
191
+ """Model to use (e.g., "gpt-5", "claude-sonnet-4"). Defaults to GITHUB_COPILOT_MODEL environment variable."""
192
+
193
+ timeout: float
194
+ """Request timeout in seconds. Defaults to GITHUB_COPILOT_TIMEOUT environment variable or 60 seconds."""
195
+
196
+ log_level: str
197
+ """CLI log level. Defaults to GITHUB_COPILOT_LOG_LEVEL environment variable."""
198
+
199
+ on_permission_request: PermissionHandlerType
200
+ """Permission request handler.
201
+ Called when Copilot requests permission to perform an action (shell, read, write, etc.).
202
+ Takes a PermissionRequest and context dict, returns PermissionRequestResult.
203
+ If not provided, all permission requests will be denied by default.
204
+ """
205
+
206
+ mcp_servers: dict[str, MCPServerConfig]
207
+ """MCP (Model Context Protocol) server configurations.
208
+ A dictionary mapping server names to their configurations.
209
+ Supports both local (stdio) and remote (HTTP/SSE) servers.
210
+ """
211
+
212
+ provider: ProviderConfig
213
+ """Custom API provider configuration for BYOK (Bring Your Own Key) scenarios.
214
+ Allows routing requests through your own OpenAI, Azure, or Anthropic endpoint
215
+ instead of the default GitHub Copilot backend.
216
+ """
217
+
218
+ instruction_directories: list[str]
219
+ """Additional directories to search for custom instruction files.
220
+ Lets applications point the CLI at project-specific or team-shared instruction
221
+ files beyond the default locations.
222
+ """
223
+
224
+ skill_directories: list[str]
225
+ """Directories containing SKILL.md files to load into the Copilot CLI session.
226
+ These are loaded natively by the Copilot CLI process, letting applications point
227
+ the CLI at project-specific or team-shared skills beyond the default locations.
228
+ """
229
+
230
+ disabled_skills: list[str]
231
+ """Names of skills to disable for the session.
232
+ Lets applications opt out of specific skills that would otherwise be discovered
233
+ from ``skill_directories`` or the default locations.
234
+ """
235
+
236
+ base_directory: str
237
+ """Directory where the CLI stores session state, configuration, and other persistent data."""
238
+
239
+ on_pre_tool_use: PreToolUseHandler
240
+ """Pre-tool-use hook handler for the Copilot SDK.
241
+
242
+ Called by the Copilot SDK before any tool is executed. The handler receives a
243
+ ``PreToolUseHookInput`` and a context dict, and returns a ``PreToolUseHookOutput``
244
+ (or ``None`` to defer). Returning ``{"permissionDecision": "ask"}`` routes the
245
+ decision to ``on_permission_request``; ``"allow"`` / ``"deny"`` gate the call
246
+ directly.
247
+
248
+ If you do **not** supply this hook, the agent installs a default ``on_pre_tool_use``
249
+ hook that returns ``"ask"`` for ``FunctionTool`` instances declared with
250
+ ``approval_mode="always_require"`` (deferring all other tools), so those tools are
251
+ gated through ``on_permission_request``. If you **do** supply your own hook, it
252
+ takes precedence and **you** are responsible for enforcing approval for any
253
+ ``always_require`` tool; the agent logs a warning naming such tools."""
254
+
255
+ on_function_approval: FunctionApprovalCallback
256
+ """Deprecated approval callback for ``FunctionTool`` instances declared with
257
+ ``approval_mode="always_require"``.
258
+
259
+ .. deprecated::
260
+ Use ``on_pre_tool_use`` together with ``on_permission_request`` instead.
261
+ When neither this callback nor ``on_pre_tool_use`` is set, the agent
262
+ installs a default ``on_pre_tool_use`` hook that returns ``"ask"`` for
263
+ ``always_require`` tools and routes the decision to ``on_permission_request``.
264
+
265
+ When set, this callback is enforced inside the SDK tool-handler before the tool
266
+ runs; a falsy return value denies the call. Setting it emits a
267
+ ``DeprecationWarning``. It is **mutually exclusive** with ``on_pre_tool_use`` —
268
+ setting both raises ``ValueError``."""
269
+
270
+
271
+ OptionsT = TypeVar(
272
+ "OptionsT",
273
+ bound=TypedDict, # type: ignore[valid-type]
274
+ default="GitHubCopilotOptions",
275
+ covariant=True,
276
+ )
277
+
278
+
279
+ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
280
+ """A GitHub Copilot Agent without telemetry layers.
281
+
282
+ This is the core GitHub Copilot agent implementation without OpenTelemetry instrumentation.
283
+ For most use cases, prefer :class:`GitHubCopilotAgent` which includes telemetry support.
284
+
285
+ This agent wraps the GitHub Copilot SDK to provide Copilot agentic capabilities
286
+ within the Agent Framework. It supports both streaming and non-streaming responses,
287
+ custom tools, and session management.
288
+
289
+ The agent can be used as an async context manager to ensure proper cleanup:
290
+
291
+ Examples:
292
+ Basic usage:
293
+
294
+ .. code-block:: python
295
+
296
+ async with RawGitHubCopilotAgent() as agent:
297
+ response = await agent.run("Hello, world!")
298
+ print(response)
299
+
300
+ With explicitly typed options:
301
+
302
+ .. code-block:: python
303
+
304
+ from agent_framework_github_copilot import RawGitHubCopilotAgent, GitHubCopilotOptions
305
+
306
+ agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent(
307
+ default_options={"model": "claude-sonnet-4", "timeout": 120}
308
+ )
309
+ """
310
+
311
+ AGENT_PROVIDER_NAME: ClassVar[str] = "github.copilot"
312
+
313
+ def __init__(
314
+ self,
315
+ instructions: str | None = None,
316
+ *,
317
+ client: CopilotClient | None = None,
318
+ id: str | None = None,
319
+ name: str | None = None,
320
+ description: str | None = None,
321
+ context_providers: Sequence[ContextProvider] | None = None,
322
+ middleware: Sequence[AgentMiddlewareTypes] | None = None,
323
+ tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
324
+ default_options: OptionsT | None = None,
325
+ env_file_path: str | None = None,
326
+ env_file_encoding: str | None = None,
327
+ ) -> None:
328
+ """Initialize the GitHub Copilot Agent.
329
+
330
+ Args:
331
+ instructions: System message for the agent.
332
+
333
+ Keyword Args:
334
+ client: Optional pre-configured CopilotClient instance. If not provided,
335
+ a new client will be created using the other parameters.
336
+ id: ID of the RawGitHubCopilotAgent.
337
+ name: Name of the RawGitHubCopilotAgent.
338
+ description: Description of the RawGitHubCopilotAgent.
339
+ context_providers: Context Providers, to be used by the agent.
340
+ middleware: Agent middleware used by the agent.
341
+ tools: Tools to use for the agent. Can be functions
342
+ or tool definition dicts. These are converted to Copilot SDK tools internally.
343
+ default_options: Default options for the agent. Can include cli_path, model,
344
+ timeout, log_level, etc.
345
+ env_file_path: Optional path to .env file for loading configuration.
346
+ env_file_encoding: Encoding of the .env file, defaults to 'utf-8'.
347
+
348
+ Raises:
349
+ ValueError: If required configuration is missing or invalid.
350
+ """
351
+ super().__init__(
352
+ id=id,
353
+ name=name,
354
+ description=description,
355
+ context_providers=context_providers,
356
+ middleware=list(middleware) if middleware else None,
357
+ )
358
+
359
+ self._client = client
360
+ self._owns_client = client is None
361
+
362
+ # Parse options
363
+ opts: dict[str, Any] = dict(default_options) if default_options else {}
364
+
365
+ # Handle instructions - direct parameter takes precedence over default_options.system_message
366
+ self._prepare_system_message(instructions, opts)
367
+
368
+ cli_path = opts.pop("cli_path", None)
369
+ model = opts.pop("model", None)
370
+ timeout = opts.pop("timeout", None)
371
+ log_level = opts.pop("log_level", None)
372
+ on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None)
373
+ on_pre_tool_use: PreToolUseHandler | None = opts.pop("on_pre_tool_use", None)
374
+ on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
375
+ base_directory = opts.pop("base_directory", None)
376
+
377
+ if on_function_approval is not None and on_pre_tool_use is not None:
378
+ raise ValueError(
379
+ "on_function_approval and on_pre_tool_use cannot both be set. "
380
+ "on_function_approval is deprecated; use on_pre_tool_use together with "
381
+ "on_permission_request instead."
382
+ )
383
+
384
+ if on_function_approval is not None:
385
+ warnings.warn(
386
+ "on_function_approval is deprecated and will be removed in a future version. "
387
+ "Use the SDK 'on_pre_tool_use' hook together with 'on_permission_request' instead: "
388
+ "the default 'on_pre_tool_use' hook returns 'ask' for approval_mode='always_require' "
389
+ "tools and routes the decision to 'on_permission_request'.",
390
+ DeprecationWarning,
391
+ stacklevel=2,
392
+ )
393
+
394
+ self._settings = load_settings(
395
+ GitHubCopilotSettings,
396
+ env_prefix="GITHUB_COPILOT_",
397
+ cli_path=cli_path,
398
+ model=model,
399
+ timeout=timeout,
400
+ log_level=log_level,
401
+ base_directory=base_directory,
402
+ env_file_path=env_file_path,
403
+ env_file_encoding=env_file_encoding,
404
+ )
405
+
406
+ self._tools = normalize_tools(tools)
407
+ self._permission_handler = on_permission_request
408
+ self._on_pre_tool_use: PreToolUseHandler | None = on_pre_tool_use
409
+ self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval
410
+ # Remaining options (e.g. mcp_servers, provider, instruction_directories,
411
+ # skill_directories, disabled_skills, and any other create_session parameter)
412
+ # are forwarded verbatim to the Copilot SDK by _build_session_kwargs.
413
+ self._default_options = opts
414
+ self._started = False
415
+
416
+ async def __aenter__(self) -> Self:
417
+ """Start the agent when entering async context."""
418
+ await self.start()
419
+ return self
420
+
421
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
422
+ """Stop the agent when exiting async context."""
423
+ await self.stop()
424
+
425
+ async def start(self) -> None:
426
+ """Start the Copilot client.
427
+
428
+ This method initializes the Copilot client and establishes a connection
429
+ to the Copilot CLI server. It is called automatically when using the
430
+ agent as an async context manager.
431
+
432
+ Raises:
433
+ AgentException: If the client fails to start.
434
+ """
435
+ if self._started:
436
+ return
437
+
438
+ if self._client is None:
439
+ cli_path = self._settings.get("cli_path") or None
440
+ log_level = self._settings.get("log_level") or None
441
+ base_directory = self._settings.get("base_directory") or None
442
+
443
+ client_kwargs: dict[str, Any] = {}
444
+ if cli_path:
445
+ client_kwargs["connection"] = RuntimeConnection.for_stdio(path=cli_path)
446
+ if log_level:
447
+ client_kwargs["log_level"] = log_level
448
+ if base_directory:
449
+ client_kwargs["base_directory"] = base_directory
450
+ self._client = CopilotClient(**client_kwargs)
451
+
452
+ try:
453
+ await self._client.start()
454
+ self._started = True
455
+ except Exception as ex:
456
+ raise AgentException(f"Failed to start GitHub Copilot client: {ex}") from ex
457
+
458
+ async def stop(self) -> None:
459
+ """Stop the Copilot client and clean up resources.
460
+
461
+ Stops the Copilot client if owned by this agent. The client handles
462
+ session cleanup internally. Called automatically when using the agent
463
+ as an async context manager.
464
+ """
465
+ if self._client and self._owns_client:
466
+ with contextlib.suppress(Exception):
467
+ await self._client.stop()
468
+
469
+ self._started = False
470
+
471
+ @property
472
+ def default_options(self) -> dict[str, Any]:
473
+ """Expose default options including model from settings.
474
+
475
+ Returns a merged dict of ``_default_options`` with the resolved ``model``
476
+ from settings injected under the ``model`` key. This is read by
477
+ :class:`AgentTelemetryLayer` to include the model name in span attributes.
478
+ """
479
+ opts = dict(self._default_options)
480
+ model = self._settings.get("model")
481
+ if model:
482
+ opts["model"] = model
483
+ return opts
484
+
485
+ @overload
486
+ def run(
487
+ self,
488
+ messages: AgentRunInputs | None = None,
489
+ *,
490
+ stream: Literal[False] = False,
491
+ session: AgentSession | None = None,
492
+ middleware: Sequence[AgentMiddlewareTypes] | None = None,
493
+ options: OptionsT | None = None,
494
+ **kwargs: Any,
495
+ ) -> Awaitable[AgentResponse]: ...
496
+
497
+ @overload
498
+ def run(
499
+ self,
500
+ messages: AgentRunInputs | None = None,
501
+ *,
502
+ stream: Literal[True],
503
+ session: AgentSession | None = None,
504
+ middleware: Sequence[AgentMiddlewareTypes] | None = None,
505
+ options: OptionsT | None = None,
506
+ **kwargs: Any,
507
+ ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
508
+
509
+ def run(
510
+ self,
511
+ messages: AgentRunInputs | None = None,
512
+ *,
513
+ stream: bool = False,
514
+ session: AgentSession | None = None,
515
+ middleware: Sequence[AgentMiddlewareTypes] | None = None,
516
+ options: OptionsT | None = None,
517
+ **kwargs: Any,
518
+ ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
519
+ """Get a response from the agent.
520
+
521
+ This method returns the final result of the agent's execution
522
+ as a single AgentResponse object when stream=False. When stream=True,
523
+ it returns a ResponseStream that yields AgentResponseUpdate objects.
524
+
525
+ Args:
526
+ messages: The message(s) to send to the agent.
527
+
528
+ Keyword Args:
529
+ stream: Whether to stream the response. Defaults to False.
530
+ session: The conversation session associated with the message(s).
531
+ middleware: Not used by this agent directly. Accepted for interface
532
+ compatibility; pass middleware via :class:`GitHubCopilotAgent` which
533
+ forwards it through :class:`AgentTelemetryLayer`.
534
+ options: Runtime options (model, timeout, etc.).
535
+ kwargs: Additional keyword arguments for compatibility with the shared agent
536
+ interface (e.g. compaction_strategy, tokenizer). Not used by this agent.
537
+
538
+ Returns:
539
+ When stream=False: An Awaitable[AgentResponse].
540
+ When stream=True: A ResponseStream of AgentResponseUpdate items.
541
+
542
+ Raises:
543
+ AgentException: If the request fails.
544
+ """
545
+ if middleware:
546
+ logger.warning(
547
+ "Per-run middleware is not supported by RawGitHubCopilotAgent: the GitHub Copilot SDK "
548
+ "handles tool execution internally, so chat/function middleware cannot be injected into "
549
+ "the tool call path. Use agent-level middleware via the GitHubCopilotAgent constructor instead."
550
+ )
551
+ if stream:
552
+ ctx_holder: dict[str, Any] = {}
553
+
554
+ async def _after_run_hook(response: AgentResponse) -> None:
555
+ session_context = ctx_holder.get("session_context")
556
+ sess = ctx_holder.get("session")
557
+ if session_context is not None and sess is not None:
558
+ session_context._response = response
559
+ try:
560
+ await self._run_after_providers(session=sess, context=session_context)
561
+ except Exception:
562
+ logger.exception("Error running after_run providers in streaming result hook")
563
+
564
+ def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse:
565
+ return AgentResponse.from_updates(updates)
566
+
567
+ return ResponseStream(
568
+ self._stream_updates(messages=messages, session=session, options=options, _ctx_holder=ctx_holder),
569
+ finalizer=_finalize,
570
+ result_hooks=[_after_run_hook],
571
+ )
572
+ return self._run_impl(messages=messages, session=session, options=options)
573
+
574
+ @staticmethod
575
+ def _parse_usage_details_from_copilot(data: AssistantUsageData) -> UsageDetails | None:
576
+ total_token_count = (
577
+ data.input_tokens + data.output_tokens
578
+ if data.input_tokens is not None and data.output_tokens is not None
579
+ else None
580
+ )
581
+ usage_details = UsageDetails(**{
582
+ key: value
583
+ for key, value in {
584
+ "input_token_count": data.input_tokens,
585
+ "output_token_count": data.output_tokens,
586
+ "total_token_count": total_token_count,
587
+ "cache_read_input_token_count": data.cache_read_tokens,
588
+ "cache_creation_input_token_count": data.cache_write_tokens,
589
+ "reasoning_output_token_count": data.reasoning_tokens,
590
+ }.items()
591
+ if value is not None
592
+ })
593
+ return usage_details or None
594
+
595
+ async def _run_impl(
596
+ self,
597
+ messages: AgentRunInputs | None = None,
598
+ *,
599
+ session: AgentSession | None = None,
600
+ options: OptionsT | None = None,
601
+ ) -> AgentResponse:
602
+ """Non-streaming implementation of run."""
603
+ if not self._started:
604
+ await self.start()
605
+
606
+ if not session:
607
+ session = self.create_session()
608
+
609
+ opts: dict[str, Any] = dict(options) if options else {}
610
+ if "on_function_approval" in opts:
611
+ raise ValueError(
612
+ "on_function_approval is a security-sensitive option and must be set "
613
+ "via default_options at agent construction time. It cannot be overridden "
614
+ "per run."
615
+ )
616
+ if "on_pre_tool_use" in opts and self._function_approval_handler is not None:
617
+ raise ValueError(
618
+ "on_pre_tool_use cannot be combined with the deprecated on_function_approval "
619
+ "(set via default_options). Remove on_function_approval and use on_pre_tool_use "
620
+ "together with on_permission_request instead."
621
+ )
622
+ timeout = opts.get("timeout") or self._settings.get("timeout") or DEFAULT_TIMEOUT_SECONDS
623
+
624
+ input_messages = normalize_messages(messages)
625
+
626
+ session_context = await self._run_before_providers(session=session, input_messages=input_messages, options=opts)
627
+
628
+ # Merge provider-contributed tools into runtime_options before session creation.
629
+ if session_context.tools:
630
+ existing = list(opts.get("tools") or [])
631
+ opts["tools"] = existing + list(session_context.tools)
632
+
633
+ copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts)
634
+ usage_details: UsageDetails | None = None
635
+ finish_reason: str | None = None
636
+ model: str | None = None
637
+
638
+ def usage_event_handler(event: SessionEvent) -> None:
639
+ nonlocal usage_details, finish_reason, model
640
+ if event.type != SessionEventType.ASSISTANT_USAGE:
641
+ return
642
+ if isinstance(event.data, AssistantUsageData):
643
+ parsed_usage_details = self._parse_usage_details_from_copilot(event.data)
644
+ if parsed_usage_details:
645
+ usage_details = add_usage_details(usage_details, parsed_usage_details)
646
+ event_finish_reason = (
647
+ "content_filter" if event.data.content_filter_triggered else event.data.finish_reason
648
+ )
649
+ if event_finish_reason:
650
+ finish_reason = event_finish_reason
651
+ if event.data.model:
652
+ model = event.data.model
653
+ else:
654
+ logger.warning(
655
+ "Ignoring GitHub Copilot assistant usage event with unexpected payload type: %s",
656
+ type(event.data).__name__,
657
+ )
658
+
659
+ # Build the prompt from the full set of messages in the session context,
660
+ # so that any context/history provider-injected messages are included.
661
+ context_messages = session_context.get_messages(include_input=True)
662
+ prompt = "\n".join([message.text for message in context_messages])
663
+ if session_context.instructions:
664
+ prompt = "\n".join(session_context.instructions) + "\n" + prompt
665
+ attachments = self._prepare_attachments_for_copilot(context_messages)
666
+
667
+ unsubscribe = copilot_session.on(usage_event_handler)
668
+ try:
669
+ response_event = await copilot_session.send_and_wait(prompt, attachments=attachments, timeout=timeout)
670
+ except Exception as ex:
671
+ raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
672
+ finally:
673
+ unsubscribe()
674
+
675
+ response_messages: list[Message] = []
676
+ response_id: str | None = None
677
+
678
+ # send_and_wait returns only the final ASSISTANT_MESSAGE event;
679
+ # other events (deltas, tool calls) are handled internally by the SDK.
680
+ if response_event and response_event.type == SessionEventType.ASSISTANT_MESSAGE:
681
+ data: Any = response_event.data
682
+ message_id = data.message_id
683
+
684
+ if data.content:
685
+ response_messages.append(
686
+ Message(
687
+ role="assistant",
688
+ contents=[Content.from_text(data.content)],
689
+ message_id=message_id,
690
+ raw_representation=response_event,
691
+ )
692
+ )
693
+ response_id = message_id
694
+
695
+ response = AgentResponse(
696
+ messages=response_messages,
697
+ response_id=response_id,
698
+ finish_reason=cast(Any, finish_reason),
699
+ usage_details=usage_details,
700
+ additional_properties={"model": model} if model else None,
701
+ )
702
+ session_context._response = response # type: ignore[assignment]
703
+ await self._run_after_providers(session=session, context=session_context)
704
+ return response
705
+
706
+ async def _stream_updates(
707
+ self,
708
+ messages: AgentRunInputs | None = None,
709
+ *,
710
+ session: AgentSession | None = None,
711
+ options: OptionsT | None = None,
712
+ _ctx_holder: dict[str, Any] | None = None,
713
+ ) -> AsyncIterable[AgentResponseUpdate]:
714
+ """Internal method to stream updates from GitHub Copilot.
715
+
716
+ Args:
717
+ messages: The message(s) to send to the agent.
718
+
719
+ Keyword Args:
720
+ session: The conversation session associated with the message(s).
721
+ options: Runtime options (model, timeout, etc.).
722
+ _ctx_holder: Internal dict populated with session_context and session
723
+ so that the caller (via a ResponseStream result_hook) can run
724
+ after_run providers without duplicating the updates buffer.
725
+
726
+ Yields:
727
+ AgentResponseUpdate items.
728
+
729
+ Raises:
730
+ AgentException: If the request fails.
731
+ """
732
+ if not self._started:
733
+ await self.start()
734
+
735
+ if not session:
736
+ session = self.create_session()
737
+
738
+ opts: dict[str, Any] = dict(options) if options else {}
739
+ if "on_function_approval" in opts:
740
+ raise ValueError(
741
+ "on_function_approval is a security-sensitive option and must be set "
742
+ "via default_options at agent construction time. It cannot be overridden "
743
+ "per run."
744
+ )
745
+ if "on_pre_tool_use" in opts and self._function_approval_handler is not None:
746
+ raise ValueError(
747
+ "on_pre_tool_use cannot be combined with the deprecated on_function_approval "
748
+ "(set via default_options). Remove on_function_approval and use on_pre_tool_use "
749
+ "together with on_permission_request instead."
750
+ )
751
+
752
+ input_messages = normalize_messages(messages)
753
+
754
+ session_context = await self._run_before_providers(session=session, input_messages=input_messages, options=opts)
755
+
756
+ # Merge provider-contributed tools into runtime_options before session creation.
757
+ if session_context.tools:
758
+ existing = list(opts.get("tools") or [])
759
+ opts["tools"] = existing + list(session_context.tools)
760
+
761
+ copilot_session = await self._get_or_create_session(session, streaming=True, runtime_options=opts)
762
+
763
+ if _ctx_holder is not None:
764
+ _ctx_holder["session_context"] = session_context
765
+ _ctx_holder["session"] = session
766
+
767
+ # Build the prompt from the full session context so provider-injected messages are included.
768
+ context_messages = session_context.get_messages(include_input=True)
769
+ prompt = "\n".join([message.text for message in context_messages])
770
+ if session_context.instructions:
771
+ prompt = "\n".join(session_context.instructions) + "\n" + prompt
772
+ attachments = self._prepare_attachments_for_copilot(context_messages)
773
+
774
+ queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue()
775
+
776
+ def event_handler(event: SessionEvent) -> None:
777
+ if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA:
778
+ data: Any = event.data
779
+ if data.delta_content:
780
+ update = AgentResponseUpdate(
781
+ role="assistant",
782
+ contents=[Content.from_text(data.delta_content)],
783
+ response_id=data.message_id,
784
+ message_id=data.message_id,
785
+ raw_representation=event,
786
+ )
787
+ queue.put_nowait(update)
788
+ elif event.type == SessionEventType.ASSISTANT_USAGE:
789
+ if not isinstance(event.data, AssistantUsageData):
790
+ logger.warning(
791
+ "Ignoring GitHub Copilot assistant usage event with unexpected payload type: %s",
792
+ type(event.data).__name__,
793
+ )
794
+ return
795
+ usage_details = self._parse_usage_details_from_copilot(event.data)
796
+ finish_reason = "content_filter" if event.data.content_filter_triggered else event.data.finish_reason
797
+ model = event.data.model or None
798
+ if usage_details or finish_reason or model:
799
+ update = AgentResponseUpdate(
800
+ contents=[Content.from_usage(usage_details, raw_representation=event.data)]
801
+ if usage_details
802
+ else None,
803
+ finish_reason=cast(Any, finish_reason),
804
+ additional_properties={"model": model} if model else None,
805
+ raw_representation=event,
806
+ )
807
+ queue.put_nowait(update)
808
+ elif event.type == SessionEventType.TOOL_EXECUTION_START:
809
+ tool_call_id = getattr(event.data, "tool_call_id", None) or ""
810
+ tool_name = getattr(event.data, "tool_name", None) or ""
811
+ arguments = getattr(event.data, "arguments", None)
812
+ fc = Content.from_function_call(
813
+ call_id=tool_call_id,
814
+ name=tool_name,
815
+ arguments=arguments,
816
+ raw_representation=event.data,
817
+ )
818
+ update = AgentResponseUpdate(
819
+ role="assistant",
820
+ contents=[fc],
821
+ raw_representation=event,
822
+ )
823
+ queue.put_nowait(update)
824
+ elif event.type == SessionEventType.TOOL_EXECUTION_COMPLETE:
825
+ tool_call_id = getattr(event.data, "tool_call_id", None) or ""
826
+ result_obj = getattr(event.data, "result", None)
827
+ result_text = getattr(result_obj, "content", "") if result_obj else ""
828
+ success = getattr(event.data, "success", None)
829
+ error_val = getattr(event.data, "error", None)
830
+ exception = None
831
+ if success is False and error_val is not None:
832
+ exception = error_val.message if hasattr(error_val, "message") else str(error_val)
833
+ fr = Content.from_function_result(
834
+ call_id=tool_call_id,
835
+ result=result_text or "",
836
+ exception=exception,
837
+ raw_representation=event.data,
838
+ )
839
+ update = AgentResponseUpdate(
840
+ role="tool",
841
+ contents=[fr],
842
+ raw_representation=event,
843
+ )
844
+ queue.put_nowait(update)
845
+ elif event.type == SessionEventType.SESSION_IDLE:
846
+ queue.put_nowait(None)
847
+ elif event.type == SessionEventType.SESSION_ERROR:
848
+ error_data: Any = event.data
849
+ error_msg = error_data.message or "Unknown error"
850
+ queue.put_nowait(AgentException(f"GitHub Copilot session error: {error_msg}"))
851
+
852
+ unsubscribe = copilot_session.on(event_handler)
853
+
854
+ try:
855
+ await copilot_session.send(prompt, attachments=attachments)
856
+
857
+ while (item := await queue.get()) is not None:
858
+ if isinstance(item, Exception):
859
+ raise item
860
+ yield item
861
+ finally:
862
+ unsubscribe()
863
+
864
+ async def _run_before_providers(
865
+ self,
866
+ *,
867
+ session: AgentSession,
868
+ input_messages: list[Message],
869
+ options: dict[str, Any],
870
+ ) -> SessionContext:
871
+ """Run before_run on all context providers and return the session context.
872
+
873
+ Creates a SessionContext and invokes ``before_run`` on each provider in
874
+ forward order. ``HistoryProvider`` instances with
875
+ ``load_messages=False`` are skipped.
876
+
877
+ Keyword Args:
878
+ session: The conversation session.
879
+ input_messages: The normalized input messages.
880
+ options: Runtime options dict.
881
+
882
+ Returns:
883
+ The SessionContext with provider context populated.
884
+ """
885
+ session_context = SessionContext(
886
+ session_id=session.session_id,
887
+ service_session_id=session.service_session_id,
888
+ input_messages=input_messages,
889
+ options=options,
890
+ )
891
+
892
+ for provider in self.context_providers:
893
+ if isinstance(provider, HistoryProvider) and not provider.load_messages:
894
+ continue
895
+ await provider.before_run(
896
+ agent=self,
897
+ session=session,
898
+ context=session_context,
899
+ state=session.state.setdefault(provider.source_id, {}),
900
+ )
901
+
902
+ return session_context
903
+
904
+ @staticmethod
905
+ def _prepare_system_message(
906
+ instructions: str | None,
907
+ opts: dict[str, Any],
908
+ ) -> None:
909
+ """Prepare system message configuration in opts.
910
+
911
+ If instructions is provided, it takes precedence for content.
912
+ If system_message is also provided, its mode is preserved.
913
+ Modifies opts in place.
914
+
915
+ Args:
916
+ instructions: Direct instructions parameter for content.
917
+ opts: Options dictionary to modify.
918
+ """
919
+ opts_system_message = opts.pop("system_message", None)
920
+ if instructions is not None:
921
+ # Use instructions for content, but preserve mode from system_message if provided
922
+ mode = opts_system_message.get("mode", "append") if opts_system_message else "append"
923
+ opts["system_message"] = {"mode": mode, "content": instructions}
924
+ elif opts_system_message is not None:
925
+ opts["system_message"] = opts_system_message
926
+
927
+ @staticmethod
928
+ def _prepare_attachments_for_copilot(messages: Sequence[Message]) -> list[Attachment] | None:
929
+ """Convert inline binary message content into Copilot SDK attachments.
930
+
931
+ Scans the outgoing messages for ``data`` content (binary payloads such as
932
+ images or documents carried as base64 data URIs) and maps each one to an
933
+ inline ``blob`` attachment understood by the Copilot SDK.
934
+
935
+ Only base64 ``data:`` content is forwarded as an attachment. Other content
936
+ is not turned into an attachment: text content is already carried in the
937
+ prompt, while remote URIs (for example ``https://`` links) and malformed or
938
+ non-base64 ``data:`` URIs are skipped -- they are neither attached nor added
939
+ to the prompt.
940
+
941
+ Args:
942
+ messages: The messages being sent to the Copilot session.
943
+
944
+ Returns:
945
+ A list of Copilot ``Attachment`` objects, or ``None`` when the messages
946
+ contain no attachable binary content.
947
+ """
948
+ attachments: list[Attachment] = []
949
+ for message in messages:
950
+ for content in message.contents:
951
+ if content.type != "data":
952
+ continue
953
+ try:
954
+ data_str = _get_data_bytes_as_str(content)
955
+ except ContentError:
956
+ logger.warning(
957
+ "Skipping GitHub Copilot attachment with an unsupported data URI; "
958
+ "only base64-encoded 'data:' URIs can be forwarded as attachments."
959
+ )
960
+ continue
961
+ if not data_str:
962
+ continue
963
+ if not content.media_type:
964
+ logger.warning(
965
+ "Dropping GitHub Copilot attachment with no media type; the Copilot SDK "
966
+ "requires a MIME type for inline binary content."
967
+ )
968
+ continue
969
+ blob: BlobAttachment = {
970
+ "type": "blob",
971
+ "data": data_str,
972
+ "mimeType": content.media_type,
973
+ }
974
+ attachments.append(blob)
975
+ return attachments or None
976
+
977
+ def _prepare_tools(
978
+ self,
979
+ tools: Sequence[ToolTypes | CopilotTool],
980
+ ) -> list[CopilotTool]:
981
+ """Convert Agent Framework tools to Copilot SDK tools.
982
+
983
+ Args:
984
+ tools: List of Agent Framework tools.
985
+
986
+ Returns:
987
+ List of Copilot SDK tools.
988
+ """
989
+ copilot_tools: list[CopilotTool] = []
990
+
991
+ for tool in tools:
992
+ if isinstance(tool, CopilotTool):
993
+ copilot_tools.append(tool)
994
+ elif isinstance(tool, FunctionTool):
995
+ copilot_tools.append(self._tool_to_copilot_tool(tool))
996
+ elif isinstance(tool, MutableMapping):
997
+ copilot_tools.append(tool) # type: ignore[arg-type]
998
+ # Note: Other tool types (e.g., dict-based hosted tools) are skipped
999
+
1000
+ return copilot_tools
1001
+
1002
+ def _tool_to_copilot_tool(self, ai_func: FunctionTool) -> CopilotTool:
1003
+ """Convert an FunctionTool to a Copilot SDK tool.
1004
+
1005
+ Approval for tools declared with ``approval_mode="always_require"`` is normally
1006
+ enforced by the Copilot SDK's native ``on_pre_tool_use`` hook (see
1007
+ :meth:`_build_session_hooks`). When the deprecated ``on_function_approval``
1008
+ callback is configured instead, approval is enforced inside this handler for
1009
+ backward compatibility. (``on_function_approval`` and ``on_pre_tool_use`` are
1010
+ mutually exclusive, so only one mechanism is ever active.)
1011
+ """
1012
+ approval_handler = self._function_approval_handler
1013
+ enforce = approval_handler is not None and ai_func.approval_mode == "always_require"
1014
+
1015
+ async def handler(invocation: ToolInvocation) -> ToolResult:
1016
+ args: dict[str, Any] = invocation.arguments or {}
1017
+ try:
1018
+ if enforce and not await _resolve_function_approval(approval_handler, ai_func, args):
1019
+ logger.info(
1020
+ "Denying execution of tool '%s' (approval_mode='always_require', "
1021
+ "on_function_approval callback denied).",
1022
+ ai_func.name,
1023
+ )
1024
+ return ToolResult(
1025
+ text_result_for_llm=(
1026
+ f"Tool '{ai_func.name}' requires human approval "
1027
+ "(approval_mode='always_require') and the request was denied."
1028
+ ),
1029
+ result_type="failure",
1030
+ error="approval_denied",
1031
+ )
1032
+ if ai_func.input_model:
1033
+ args_instance = ai_func.input_model(**args)
1034
+ result = await ai_func.invoke(arguments=args_instance)
1035
+ else:
1036
+ result = await ai_func.invoke(arguments=args)
1037
+ rich = [c for c in result if c.type in ("data", "uri")]
1038
+ if rich:
1039
+ logger.warning(
1040
+ "GitHub Copilot does not support rich tool content; "
1041
+ f"dropping {len(rich)} non-text item(s) from '{ai_func.name}'."
1042
+ )
1043
+ text = "\n".join(c.text for c in result if c.type == "text" and c.text)
1044
+ return ToolResult(
1045
+ text_result_for_llm=text or str(result),
1046
+ result_type="success",
1047
+ )
1048
+ except Exception as e:
1049
+ return ToolResult(
1050
+ text_result_for_llm=f"Error: {e}",
1051
+ result_type="failure",
1052
+ error=str(e),
1053
+ )
1054
+
1055
+ return CopilotTool(
1056
+ name=ai_func.name,
1057
+ description=ai_func.description,
1058
+ handler=handler,
1059
+ parameters=ai_func.parameters(),
1060
+ )
1061
+
1062
+ def _build_session_hooks(
1063
+ self,
1064
+ all_tools: Sequence[ToolTypes | CopilotTool],
1065
+ options: Mapping[str, Any],
1066
+ ) -> SessionHooks | None:
1067
+ """Build the ``SessionHooks`` to pass to the Copilot SDK for this session.
1068
+
1069
+ Approval enforcement for ``FunctionTool`` instances declared with
1070
+ ``approval_mode="always_require"`` is delegated to the Copilot SDK's native
1071
+ ``on_pre_tool_use`` hook:
1072
+
1073
+ - If the caller supplies their own session hooks -- either the SDK-native
1074
+ ``hooks`` dict or the convenience ``on_pre_tool_use`` handler (via per-run
1075
+ ``options`` or ``default_options``) -- those take precedence and are used
1076
+ as-is. When both are given, the explicit ``hooks`` dict wins for any key it
1077
+ defines and the ``on_pre_tool_use`` shortcut fills in that key otherwise. A
1078
+ warning is logged naming any approval-required tool that will therefore not
1079
+ be automatically gated, since the caller's hooks are responsible for
1080
+ enforcing approval.
1081
+ - Otherwise, when any approval-required tool is present, a default hook is
1082
+ installed that returns ``"ask"`` for those tools (routing the decision to
1083
+ ``on_permission_request``) and defers (``None``) for all other tools.
1084
+ - The default hook is **not** installed when the deprecated
1085
+ ``on_function_approval`` callback is configured: in that case approval is
1086
+ enforced inside the tool handler (see :meth:`_tool_to_copilot_tool`) to
1087
+ preserve backward-compatible behavior.
1088
+ - When there are no approval-required tools and no caller hooks, ``None`` is
1089
+ returned so no hooks are registered.
1090
+
1091
+ Args:
1092
+ all_tools: The full set of tools resolved for the session.
1093
+ options: The merged session options (``default_options`` overlaid with
1094
+ per-run ``options``).
1095
+
1096
+ Returns:
1097
+ The hooks to register for the session, or ``None`` if none are needed.
1098
+ """
1099
+ user_hook: PreToolUseHandler | None = options.get("on_pre_tool_use") or self._on_pre_tool_use
1100
+ caller_hooks: Mapping[str, Any] | None = options.get("hooks")
1101
+
1102
+ # Combine caller-provided hooks: the SDK-native ``hooks`` dict plus the
1103
+ # convenience ``on_pre_tool_use`` shortcut. The explicit dict wins for the
1104
+ # keys it defines; the shortcut only fills in ``on_pre_tool_use`` otherwise.
1105
+ combined: dict[str, Any] = {}
1106
+ if user_hook is not None:
1107
+ combined["on_pre_tool_use"] = user_hook
1108
+ if caller_hooks:
1109
+ combined.update(caller_hooks)
1110
+
1111
+ approval_required_names = {
1112
+ tool.name for tool in all_tools if isinstance(tool, FunctionTool) and tool.approval_mode == "always_require"
1113
+ }
1114
+
1115
+ if combined:
1116
+ if approval_required_names:
1117
+ logger.warning(
1118
+ "Custom session hooks are configured, so %d approval-required tool(s) (%s) "
1119
+ "will not be automatically gated by GitHubCopilotAgent. The custom hooks are responsible "
1120
+ "for enforcing approval (for example, by returning a 'deny' or 'ask' decision).",
1121
+ len(approval_required_names),
1122
+ ", ".join(sorted(approval_required_names)),
1123
+ )
1124
+ return cast("SessionHooks", combined)
1125
+
1126
+ if not approval_required_names:
1127
+ return None
1128
+
1129
+ # The deprecated on_function_approval callback enforces approval in the tool
1130
+ # handler; don't also install the default ask-hook (which would double-gate).
1131
+ if self._function_approval_handler is not None:
1132
+ return None
1133
+
1134
+ def default_pre_tool_use(
1135
+ hook_input: Mapping[str, Any],
1136
+ _context: Mapping[str, str],
1137
+ ) -> PreToolUseHookOutput | None:
1138
+ tool_name = hook_input.get("toolName")
1139
+ if tool_name in approval_required_names:
1140
+ return {
1141
+ "permissionDecision": "ask",
1142
+ "permissionDecisionReason": (
1143
+ f"Tool '{tool_name}' is marked as requiring approval (approval_mode='always_require')."
1144
+ ),
1145
+ }
1146
+ return None
1147
+
1148
+ return {"on_pre_tool_use": default_pre_tool_use}
1149
+
1150
+ async def _get_or_create_session(
1151
+ self,
1152
+ agent_session: AgentSession,
1153
+ streaming: bool = False,
1154
+ runtime_options: dict[str, Any] | None = None,
1155
+ ) -> CopilotSession:
1156
+ """Get an existing session or create a new one for the session.
1157
+
1158
+ Args:
1159
+ agent_session: The conversation session.
1160
+ streaming: Whether to enable streaming for the session.
1161
+ runtime_options: Runtime options from run that take precedence.
1162
+
1163
+ Returns:
1164
+ A CopilotSession instance.
1165
+
1166
+ Raises:
1167
+ AgentException: If the session cannot be created.
1168
+ """
1169
+ if not self._client:
1170
+ raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
1171
+
1172
+ try:
1173
+ if agent_session.service_session_id:
1174
+ service_session_id = agent_session.service_session_id
1175
+ if not isinstance(service_session_id, str):
1176
+ raise AgentException(
1177
+ "GitHubCopilotAgent expects a string service_session_id for session resumption."
1178
+ )
1179
+ return await self._resume_session(service_session_id, streaming, runtime_options)
1180
+
1181
+ session = await self._create_session(streaming, runtime_options)
1182
+ agent_session.service_session_id = session.session_id
1183
+ return session
1184
+ except Exception as ex:
1185
+ raise AgentException(f"Failed to create GitHub Copilot session: {ex}") from ex
1186
+
1187
+ def _build_session_kwargs(
1188
+ self,
1189
+ streaming: bool,
1190
+ runtime_options: dict[str, Any] | None,
1191
+ ) -> dict[str, Any]:
1192
+ """Assemble keyword arguments for ``create_session`` / ``resume_session``.
1193
+
1194
+ Options are layered: the agent's ``default_options`` first, then per-run
1195
+ ``runtime_options`` which override them. Every key is forwarded verbatim to
1196
+ the Copilot SDK, so any ``create_session`` parameter is supported without a
1197
+ dedicated mapping here (an unknown name surfaces as a ``TypeError`` from the
1198
+ SDK). A few keys are handled specially because they need a secure default
1199
+ (``on_permission_request`` defaults to denying all requests) or transforming:
1200
+ ``tools`` are merged with the agent's tools and converted to SDK tools, and
1201
+ approval callbacks are turned into ``hooks``.
1202
+
1203
+ Args:
1204
+ streaming: Whether to enable streaming for the session.
1205
+ runtime_options: Runtime options that take precedence over default_options.
1206
+
1207
+ Returns:
1208
+ The keyword arguments to splat into the SDK session factory.
1209
+ """
1210
+ opts = runtime_options or {}
1211
+
1212
+ # Passthrough layer: agent defaults first, per-run options override.
1213
+ kwargs: dict[str, Any] = {**self._default_options, **opts}
1214
+
1215
+ # Merge agent-level tools with any caller-supplied tools (from default_options
1216
+ # or per-run options, the latter winning) and convert to SDK tools.
1217
+ all_tools = list(self._tools or []) + list(kwargs.get("tools") or [])
1218
+ kwargs["tools"] = self._prepare_tools(all_tools) if all_tools else None
1219
+
1220
+ kwargs["streaming"] = streaming
1221
+ # model may already be present from per-run options (merged above); otherwise fall
1222
+ # back to the resolved setting (which carries the default_options / env model).
1223
+ if not kwargs.get("model"):
1224
+ kwargs["model"] = self._settings.get("model") or None
1225
+ kwargs["on_permission_request"] = (
1226
+ opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
1227
+ )
1228
+ kwargs["hooks"] = self._build_session_hooks(all_tools, kwargs)
1229
+
1230
+ # Strip agent-internal and client-level keys that are consumed here or in the
1231
+ # run methods (and settings) but are NOT valid create_session parameters, so
1232
+ # they don't leak through the passthrough layer and raise TypeError.
1233
+ for key in ("on_pre_tool_use", "on_function_approval", "timeout", "cli_path", "log_level", "base_directory"):
1234
+ kwargs.pop(key, None)
1235
+
1236
+ return kwargs
1237
+
1238
+ async def _create_session(
1239
+ self,
1240
+ streaming: bool,
1241
+ runtime_options: dict[str, Any] | None = None,
1242
+ ) -> CopilotSession:
1243
+ """Create a new Copilot session.
1244
+
1245
+ Args:
1246
+ streaming: Whether to enable streaming for the session.
1247
+ runtime_options: Runtime options that take precedence over default_options.
1248
+ """
1249
+ if not self._client:
1250
+ raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
1251
+
1252
+ return await self._client.create_session(**self._build_session_kwargs(streaming, runtime_options))
1253
+
1254
+ async def _resume_session(
1255
+ self,
1256
+ session_id: str,
1257
+ streaming: bool,
1258
+ runtime_options: dict[str, Any] | None = None,
1259
+ ) -> CopilotSession:
1260
+ """Resume an existing Copilot session by ID.
1261
+
1262
+ Args:
1263
+ session_id: The session ID to resume.
1264
+ streaming: Whether to enable streaming for the session.
1265
+ runtime_options: Runtime options that take precedence over default_options.
1266
+ """
1267
+ if not self._client:
1268
+ raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
1269
+
1270
+ return await self._client.resume_session(session_id, **self._build_session_kwargs(streaming, runtime_options))
1271
+
1272
+
1273
+ class GitHubCopilotAgent( # type: ignore[misc]
1274
+ AgentMiddlewareLayer,
1275
+ AgentTelemetryLayer,
1276
+ RawGitHubCopilotAgent[OptionsT],
1277
+ Generic[OptionsT],
1278
+ ):
1279
+ """A GitHub Copilot Agent with full middleware and telemetry support.
1280
+
1281
+ This is the recommended agent class for most use cases. It includes
1282
+ middleware support and OpenTelemetry-based telemetry for observability,
1283
+ with middleware running outside the telemetry span so middleware execution
1284
+ time is not captured in traces. For a minimal implementation without these
1285
+ layers, use :class:`RawGitHubCopilotAgent`.
1286
+
1287
+ Examples:
1288
+ Basic usage:
1289
+
1290
+ .. code-block:: python
1291
+
1292
+ async with GitHubCopilotAgent() as agent:
1293
+ response = await agent.run("Hello, world!")
1294
+ print(response)
1295
+
1296
+ With explicitly typed options:
1297
+
1298
+ .. code-block:: python
1299
+
1300
+ from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
1301
+
1302
+ agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
1303
+ default_options={"model": "claude-sonnet-4-5", "timeout": 120}
1304
+ )
1305
+
1306
+ With observability:
1307
+
1308
+ .. code-block:: python
1309
+
1310
+ from agent_framework.observability import configure_otel_providers
1311
+
1312
+ configure_otel_providers()
1313
+ async with GitHubCopilotAgent() as agent:
1314
+ response = await agent.run("Hello, world!")
1315
+ """
1316
+
1317
+ def __init__(
1318
+ self,
1319
+ instructions: str | None = None,
1320
+ *,
1321
+ client: CopilotClient | None = None,
1322
+ id: str | None = None,
1323
+ name: str | None = None,
1324
+ description: str | None = None,
1325
+ context_providers: Sequence[ContextProvider] | None = None,
1326
+ middleware: Sequence[AgentMiddlewareTypes] | None = None,
1327
+ tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
1328
+ default_options: OptionsT | None = None,
1329
+ env_file_path: str | None = None,
1330
+ env_file_encoding: str | None = None,
1331
+ ) -> None:
1332
+ """Initialize a GitHub Copilot Agent with full middleware and telemetry.
1333
+
1334
+ Args:
1335
+ instructions: System message for the agent.
1336
+
1337
+ Keyword Args:
1338
+ client: Optional pre-configured CopilotClient instance. If not provided,
1339
+ a new client will be created using the other parameters.
1340
+ id: ID of the agent.
1341
+ name: Name of the agent.
1342
+ description: Description of the agent.
1343
+ context_providers: Context providers to be used by the agent.
1344
+ middleware: Agent middleware used by the agent.
1345
+ tools: Tools to use for the agent. Can be functions or tool definition dicts.
1346
+ These are converted to Copilot SDK tools internally.
1347
+ default_options: Default options for the agent. Can include cli_path, model,
1348
+ timeout, log_level, etc.
1349
+ env_file_path: Optional path to .env file for loading configuration.
1350
+ env_file_encoding: Encoding of the .env file, defaults to 'utf-8'.
1351
+ """
1352
+ super().__init__(
1353
+ instructions,
1354
+ client=client,
1355
+ id=id,
1356
+ name=name,
1357
+ description=description,
1358
+ context_providers=context_providers,
1359
+ middleware=middleware,
1360
+ tools=tools,
1361
+ default_options=default_options,
1362
+ env_file_path=env_file_path,
1363
+ env_file_encoding=env_file_encoding,
1364
+ )