agent-framework-github-copilot 1.0.3__tar.gz → 2.0.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-framework-github-copilot
3
- Version: 1.0.3
3
+ Version: 2.0.1
4
4
  Summary: GitHub Copilot integration for Microsoft Agent Framework.
5
5
  Author-email: Microsoft <af-support@microsoft.com>
6
6
  Requires-Python: >=3.11
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Typing :: Typed
17
17
  License-File: LICENSE
18
18
  Requires-Dist: agent-framework-core>=1.15.0,<2
19
- Requires-Dist: github-copilot-sdk==1.0.2; python_version >= '3.11'
19
+ Requires-Dist: github-copilot-sdk==1.0.11; python_version >= '3.11'
20
20
  Project-URL: homepage, https://aka.ms/agent-framework
21
21
  Project-URL: issues, https://github.com/microsoft/agent-framework/issues
22
22
  Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
@@ -111,4 +111,33 @@ model described above. When `on_function_approval` is set, it gates `always_requ
111
111
  the default ask-hook is not installed. It is **mutually exclusive** with `on_pre_tool_use` —
112
112
  setting both (whether at construction or per run) raises `ValueError`.
113
113
 
114
+ ## Workspace-driven session options
115
+
116
+ `on_permission_request` and `on_pre_tool_use` gate **tool calls**. They do not cover
117
+ configuration the CLI picks up from the working directory it runs in, which is a separate
118
+ mechanism with its own switches.
119
+
120
+ So that a session behaves the same way in every checkout, `GitHubCopilotAgent` leaves the
121
+ following off by default:
122
+
123
+ | Option | Default | Effect when enabled |
124
+ | --- | --- | --- |
125
+ | `enable_file_hooks` | `False` | The CLI loads file hooks from the working directory's `.github/hooks/` and runs the commands they define, independently of the tool-approval path. |
126
+
127
+ Opt in per agent or per run when your workflow needs the checkout to drive the session:
128
+
129
+ ```python
130
+ agent = GitHubCopilotAgent(
131
+ default_options=GitHubCopilotOptions(enable_file_hooks=True),
132
+ )
133
+ ```
134
+
135
+ Only enable these for a working directory whose contents you trust to act on the host.
136
+
137
+ To make the default visible rather than silent, the agent logs a warning through the
138
+ `agent_framework.github_copilot` logger the first time it starts a session in a working
139
+ directory that defines hooks it is not loading. See
140
+ [`github_copilot_with_file_hooks.py`](../../samples/02-agents/providers/github_copilot/github_copilot_with_file_hooks.py)
141
+ for a runnable example.
142
+
114
143
 
@@ -87,3 +87,32 @@ model described above. When `on_function_approval` is set, it gates `always_requ
87
87
  the default ask-hook is not installed. It is **mutually exclusive** with `on_pre_tool_use` —
88
88
  setting both (whether at construction or per run) raises `ValueError`.
89
89
 
90
+ ## Workspace-driven session options
91
+
92
+ `on_permission_request` and `on_pre_tool_use` gate **tool calls**. They do not cover
93
+ configuration the CLI picks up from the working directory it runs in, which is a separate
94
+ mechanism with its own switches.
95
+
96
+ So that a session behaves the same way in every checkout, `GitHubCopilotAgent` leaves the
97
+ following off by default:
98
+
99
+ | Option | Default | Effect when enabled |
100
+ | --- | --- | --- |
101
+ | `enable_file_hooks` | `False` | The CLI loads file hooks from the working directory's `.github/hooks/` and runs the commands they define, independently of the tool-approval path. |
102
+
103
+ Opt in per agent or per run when your workflow needs the checkout to drive the session:
104
+
105
+ ```python
106
+ agent = GitHubCopilotAgent(
107
+ default_options=GitHubCopilotOptions(enable_file_hooks=True),
108
+ )
109
+ ```
110
+
111
+ Only enable these for a working directory whose contents you trust to act on the host.
112
+
113
+ To make the default visible rather than silent, the agent logs a warning through the
114
+ `agent_framework.github_copilot` logger the first time it starts a session in a working
115
+ directory that defines hooks it is not loading. See
116
+ [`github_copilot_with_file_hooks.py`](../../samples/02-agents/providers/github_copilot/github_copilot_with_file_hooks.py)
117
+ for a runnable example.
118
+
@@ -9,7 +9,9 @@ import json
9
9
  import logging
10
10
  import sys
11
11
  import warnings
12
- from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
12
+ import weakref
13
+ from collections.abc import AsyncGenerator, AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
14
+ from pathlib import Path
13
15
  from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload
14
16
  from urllib.parse import urlparse
15
17
 
@@ -22,6 +24,7 @@ from agent_framework import (
22
24
  BaseAgent,
23
25
  Content,
24
26
  ContextProvider,
27
+ FunctionInvocationContext,
25
28
  HistoryProvider,
26
29
  Message,
27
30
  ResponseStream,
@@ -30,6 +33,7 @@ from agent_framework import (
30
33
  add_usage_details,
31
34
  normalize_messages,
32
35
  )
36
+ from agent_framework._mcp import MCPTool
33
37
  from agent_framework._settings import load_settings
34
38
  from agent_framework._telemetry import mark_feature_used
35
39
  from agent_framework._tools import FunctionTool, ToolTypes
@@ -77,6 +81,7 @@ try:
77
81
  Attachment,
78
82
  BlobAttachment,
79
83
  MCPServerConfig,
84
+ PermissionInvocation,
80
85
  PermissionRequestResult,
81
86
  PreToolUseHandler,
82
87
  PreToolUseHookOutput,
@@ -110,14 +115,24 @@ except ImportError as _copilot_import_error:
110
115
  DEFAULT_TIMEOUT_SECONDS: float = 60.0
111
116
  """Default timeout in seconds for Copilot requests."""
112
117
 
118
+ _PermissionHandlerContext = Any
119
+ """Compatibility context accepted by permission handlers across SDK versions."""
120
+
113
121
  PermissionHandlerType = Callable[
114
- [PermissionRequest, dict[str, str]], "PermissionRequestResult | Awaitable[PermissionRequestResult]"
122
+ [PermissionRequest, _PermissionHandlerContext],
123
+ "PermissionRequestResult | Awaitable[PermissionRequestResult]",
115
124
  ]
116
125
  """Type for permission request handlers. Supports both sync and async callbacks."""
117
126
 
118
- AsyncPermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], "Awaitable[PermissionRequestResult]"]
127
+ AsyncPermissionHandlerType = Callable[
128
+ [PermissionRequest, _PermissionHandlerContext], "Awaitable[PermissionRequestResult]"
129
+ ]
119
130
  """Type for permission request handlers that are always asynchronous."""
120
131
 
132
+ _SdkAsyncPermissionHandlerType = Callable[
133
+ [PermissionRequest, PermissionInvocation], "Awaitable[PermissionRequestResult]"
134
+ ]
135
+
121
136
 
122
137
  FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
123
138
  """Deprecated approval callback for ``FunctionTool`` instances declared with
@@ -168,10 +183,30 @@ async def _resolve_function_approval(
168
183
 
169
184
  logger = logging.getLogger("agent_framework.github_copilot")
170
185
 
186
+ # One lock per (CopilotClient, service session ID), shared across agents that share a client.
187
+ # Entries disappear once no run holds or awaits the lock.
188
+ _SESSION_RUN_LOCKS: weakref.WeakValueDictionary[tuple[int, str], asyncio.Lock] = weakref.WeakValueDictionary()
189
+
190
+ _MCP_TOOL_MESSAGE = (
191
+ "MCP server '{name}' cannot be passed to GitHubCopilotAgent as a tool: the Copilot SDK "
192
+ "connects to MCP servers itself, so a framework-managed MCPTool would keep none of its "
193
+ "framework behavior. Configure the server natively instead, for example "
194
+ "default_options={{'mcp_servers': {{'{name}': {{'type': 'stdio', 'command': 'python', "
195
+ "'args': ['server.py'], 'tools': ['*']}}}}}}, or use a ChatAgent, where the framework owns "
196
+ "the connection."
197
+ )
198
+
199
+
200
+ def _reject_mcp_tools(tools: Sequence[Any]) -> None:
201
+ """Refuse MCP servers handed in as tools, from whichever option carried them."""
202
+ for tool in tools:
203
+ if isinstance(tool, MCPTool):
204
+ raise TypeError(_MCP_TOOL_MESSAGE.format(name=tool.name))
205
+
171
206
 
172
207
  def _deny_all_permissions(
173
208
  _request: PermissionRequest,
174
- _invocation: dict[str, str],
209
+ _invocation: _PermissionHandlerContext,
175
210
  ) -> PermissionRequestResult:
176
211
  """Default permission handler that denies all requests."""
177
212
  return PermissionDecisionUserNotAvailable()
@@ -322,7 +357,7 @@ def _normalize_permission_decision(
322
357
  return PermissionDecisionApproveForSession(approval=approval)
323
358
 
324
359
 
325
- def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> AsyncPermissionHandlerType:
360
+ def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> _SdkAsyncPermissionHandlerType:
326
361
  """Wrap a permission handler so its decisions are normalized before reaching the SDK.
327
362
 
328
363
  Exceptions raised by ``handler`` deliberately propagate: the SDK already catches them
@@ -335,8 +370,10 @@ def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> Asy
335
370
  An async handler delegating to ``handler`` and normalizing its result.
336
371
  """
337
372
 
338
- async def normalized_handler(request: PermissionRequest, invocation: dict[str, str]) -> PermissionRequestResult:
339
- result = handler(request, invocation)
373
+ async def normalized_handler(
374
+ request: PermissionRequest, invocation: PermissionInvocation
375
+ ) -> PermissionRequestResult:
376
+ result = handler(request, cast(PermissionInvocation, dict(invocation)))
340
377
  if inspect.isawaitable(result):
341
378
  result = await result
342
379
  return _normalize_permission_decision(result, request)
@@ -364,6 +401,45 @@ def _parse_telemetry_config(raw: str) -> TelemetryConfig | None:
364
401
  return cast(TelemetryConfig, parsed)
365
402
 
366
403
 
404
+ def _client_working_directory(client: CopilotClient | None) -> str | None:
405
+ """Best-effort read of the working directory an injected client configured.
406
+
407
+ A client created with ``CopilotClient(working_directory=...)`` spawns the CLI process
408
+ in that directory, so the CLI resolves ``.github/hooks/`` relative to it rather than to
409
+ this process. The SDK keeps the value on a private options object, so this is read
410
+ defensively: if the attribute ever moves, hook detection falls back to the process
411
+ working directory instead of failing.
412
+ """
413
+ options = getattr(client, "_options", None)
414
+ working_directory = getattr(options, "working_directory", None)
415
+ return working_directory if isinstance(working_directory, str) else None
416
+
417
+
418
+ def _resolve_effective_working_directory(session_working_directory: str | None, client: CopilotClient | None) -> Path:
419
+ """Resolve the directory the CLI will treat as the workspace, mirroring the SDK.
420
+
421
+ The SDK resolves in this order: the session's ``working_directory`` when supplied,
422
+ otherwise the CLI process's own directory, which is the injected client's configured
423
+ ``working_directory`` when it set one and this process's directory otherwise.
424
+ """
425
+ if session_working_directory:
426
+ return Path(session_working_directory)
427
+ client_working_directory = _client_working_directory(client)
428
+ if client_working_directory:
429
+ return Path(client_working_directory)
430
+ return Path.cwd()
431
+
432
+
433
+ def _has_file_hooks(working_directory: Path) -> bool:
434
+ """Return whether ``.github/hooks/`` holds hook definitions the CLI would otherwise load."""
435
+ try:
436
+ return any((working_directory / ".github" / "hooks").iterdir())
437
+ except OSError:
438
+ # Missing directory, unreadable path, or a file where the directory would be:
439
+ # there is nothing the caller needs to know about.
440
+ return False
441
+
442
+
367
443
  class GitHubCopilotSettings(TypedDict, total=False):
368
444
  """GitHub Copilot model settings.
369
445
 
@@ -468,6 +544,14 @@ class GitHubCopilotOptions(TypedDict, total=False):
468
544
  base_directory: str
469
545
  """Directory where the CLI stores session state, configuration, and other persistent data."""
470
546
 
547
+ enable_file_hooks: bool
548
+ """Whether the CLI loads file hooks from the working directory's ``.github/hooks/``.
549
+
550
+ Defaults to ``False``: hook definitions checked into the working directory are ignored
551
+ unless you opt in, so a session behaves the same way regardless of which checkout it
552
+ runs in. Unrelated to the SDK callback hooks configured through ``on_pre_tool_use``.
553
+ """
554
+
471
555
  telemetry: TelemetryConfig
472
556
  """OpenTelemetry configuration for the Copilot CLI process."""
473
557
 
@@ -641,6 +725,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
641
725
  )
642
726
 
643
727
  self._tools = normalize_tools(tools)
728
+ _reject_mcp_tools(self._tools)
644
729
  self._permission_handler = on_permission_request
645
730
  self._on_pre_tool_use: PreToolUseHandler | None = on_pre_tool_use
646
731
  self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval
@@ -649,6 +734,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
649
734
  # are forwarded verbatim to the Copilot SDK by _build_session_kwargs.
650
735
  self._default_options = opts
651
736
  self._started = False
737
+ self._file_hooks_warning_emitted = False
652
738
 
653
739
  async def __aenter__(self) -> Self:
654
740
  """Start the agent when entering async context."""
@@ -733,6 +819,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
733
819
  session: AgentSession | None = None,
734
820
  middleware: Sequence[AgentMiddlewareTypes] | None = None,
735
821
  options: OptionsT | None = None,
822
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
736
823
  **kwargs: Any,
737
824
  ) -> Awaitable[AgentResponse]: ...
738
825
 
@@ -745,6 +832,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
745
832
  session: AgentSession | None = None,
746
833
  middleware: Sequence[AgentMiddlewareTypes] | None = None,
747
834
  options: OptionsT | None = None,
835
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
748
836
  **kwargs: Any,
749
837
  ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
750
838
 
@@ -756,6 +844,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
756
844
  session: AgentSession | None = None,
757
845
  middleware: Sequence[AgentMiddlewareTypes] | None = None,
758
846
  options: OptionsT | None = None,
847
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
759
848
  **kwargs: Any,
760
849
  ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
761
850
  """Get a response from the agent.
@@ -774,6 +863,9 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
774
863
  compatibility; pass middleware via :class:`GitHubCopilotAgent` which
775
864
  forwards it through :class:`AgentTelemetryLayer`.
776
865
  options: Runtime options (model, timeout, etc.).
866
+ function_invocation_kwargs: Keyword arguments forwarded to tool invocations.
867
+ Tools receive them through ``FunctionInvocationContext.kwargs`` when they
868
+ declare a ``FunctionInvocationContext`` parameter.
777
869
  kwargs: Additional keyword arguments for compatibility with the shared agent
778
870
  interface (e.g. compaction_strategy, tokenizer). Not used by this agent.
779
871
 
@@ -807,11 +899,22 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
807
899
  return AgentResponse.from_updates(updates)
808
900
 
809
901
  return ResponseStream(
810
- self._stream_updates(messages=messages, session=session, options=options, _ctx_holder=ctx_holder),
902
+ self._stream_updates(
903
+ messages=messages,
904
+ session=session,
905
+ options=options,
906
+ function_invocation_kwargs=function_invocation_kwargs,
907
+ _ctx_holder=ctx_holder,
908
+ ),
811
909
  finalizer=_finalize,
812
910
  result_hooks=[_after_run_hook],
813
911
  )
814
- return self._run_impl(messages=messages, session=session, options=options)
912
+ return self._run_impl(
913
+ messages=messages,
914
+ session=session,
915
+ options=options,
916
+ function_invocation_kwargs=function_invocation_kwargs,
917
+ )
815
918
 
816
919
  @staticmethod
817
920
  def _parse_usage_details_from_copilot(data: AssistantUsageData) -> UsageDetails | None:
@@ -840,6 +943,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
840
943
  *,
841
944
  session: AgentSession | None = None,
842
945
  options: OptionsT | None = None,
946
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
843
947
  ) -> AgentResponse:
844
948
  """Non-streaming implementation of run."""
845
949
  if not self._started:
@@ -872,7 +976,6 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
872
976
  existing = list(opts.get("tools") or [])
873
977
  opts["tools"] = existing + list(session_context.tools)
874
978
 
875
- copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts)
876
979
  usage_details: UsageDetails | None = None
877
980
  finish_reason: str | None = None
878
981
  model: str | None = None
@@ -906,14 +1009,20 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
906
1009
  prompt = "\n".join(session_context.instructions) + "\n" + prompt
907
1010
  attachments = self._prepare_attachments_for_copilot(context_messages)
908
1011
 
909
- unsubscribe = copilot_session.on(usage_event_handler)
910
- try:
911
- mark_feature_used(FeatureIndex.GITHUB_COPILOT)
912
- response_event = await copilot_session.send_and_wait(prompt, attachments=attachments, timeout=timeout)
913
- except Exception as ex:
914
- raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
915
- finally:
916
- unsubscribe()
1012
+ async with self._session_run_scope(
1013
+ session,
1014
+ streaming=False,
1015
+ runtime_options=opts,
1016
+ function_invocation_kwargs=function_invocation_kwargs,
1017
+ ) as copilot_session:
1018
+ unsubscribe = copilot_session.on(usage_event_handler)
1019
+ try:
1020
+ mark_feature_used(FeatureIndex.GITHUB_COPILOT)
1021
+ response_event = await copilot_session.send_and_wait(prompt, attachments=attachments, timeout=timeout)
1022
+ except Exception as ex:
1023
+ raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
1024
+ finally:
1025
+ unsubscribe()
917
1026
 
918
1027
  response_messages: list[Message] = []
919
1028
  response_id: str | None = None
@@ -952,6 +1061,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
952
1061
  *,
953
1062
  session: AgentSession | None = None,
954
1063
  options: OptionsT | None = None,
1064
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
955
1065
  _ctx_holder: dict[str, Any] | None = None,
956
1066
  ) -> AsyncIterable[AgentResponseUpdate]:
957
1067
  """Internal method to stream updates from GitHub Copilot.
@@ -962,6 +1072,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
962
1072
  Keyword Args:
963
1073
  session: The conversation session associated with the message(s).
964
1074
  options: Runtime options (model, timeout, etc.).
1075
+ function_invocation_kwargs: Keyword arguments forwarded to tool invocations.
965
1076
  _ctx_holder: Internal dict populated with session_context and session
966
1077
  so that the caller (via a ResponseStream result_hook) can run
967
1078
  after_run providers without duplicating the updates buffer.
@@ -1001,12 +1112,6 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1001
1112
  existing = list(opts.get("tools") or [])
1002
1113
  opts["tools"] = existing + list(session_context.tools)
1003
1114
 
1004
- copilot_session = await self._get_or_create_session(session, streaming=True, runtime_options=opts)
1005
-
1006
- if _ctx_holder is not None:
1007
- _ctx_holder["session_context"] = session_context
1008
- _ctx_holder["session"] = session
1009
-
1010
1115
  # Build the prompt from the full session context so provider-injected messages are included.
1011
1116
  context_messages = session_context.get_messages(include_input=True)
1012
1117
  prompt = "\n".join([message.text for message in context_messages])
@@ -1092,18 +1197,28 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1092
1197
  error_msg = error_data.message or "Unknown error"
1093
1198
  queue.put_nowait(AgentException(f"GitHub Copilot session error: {error_msg}"))
1094
1199
 
1095
- unsubscribe = copilot_session.on(event_handler)
1200
+ async with self._session_run_scope(
1201
+ session,
1202
+ streaming=True,
1203
+ runtime_options=opts,
1204
+ function_invocation_kwargs=function_invocation_kwargs,
1205
+ ) as copilot_session:
1206
+ if _ctx_holder is not None:
1207
+ _ctx_holder["session_context"] = session_context
1208
+ _ctx_holder["session"] = session
1096
1209
 
1097
- try:
1098
- mark_feature_used(FeatureIndex.GITHUB_COPILOT)
1099
- await copilot_session.send(prompt, attachments=attachments)
1210
+ unsubscribe = copilot_session.on(event_handler)
1100
1211
 
1101
- while (item := await queue.get()) is not None:
1102
- if isinstance(item, Exception):
1103
- raise item
1104
- yield item
1105
- finally:
1106
- unsubscribe()
1212
+ try:
1213
+ mark_feature_used(FeatureIndex.GITHUB_COPILOT)
1214
+ await copilot_session.send(prompt, attachments=attachments)
1215
+
1216
+ while (item := await queue.get()) is not None:
1217
+ if isinstance(item, Exception):
1218
+ raise item
1219
+ yield item
1220
+ finally:
1221
+ unsubscribe()
1107
1222
 
1108
1223
  async def _run_before_providers(
1109
1224
  self,
@@ -1221,12 +1336,20 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1221
1336
  def _prepare_tools(
1222
1337
  self,
1223
1338
  tools: Sequence[ToolTypes | CopilotTool],
1339
+ *,
1340
+ agent_session: AgentSession | None = None,
1341
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
1224
1342
  ) -> list[CopilotTool]:
1225
1343
  """Convert Agent Framework tools to Copilot SDK tools.
1226
1344
 
1227
1345
  Args:
1228
1346
  tools: List of Agent Framework tools.
1229
1347
 
1348
+ Keyword Args:
1349
+ agent_session: The conversation session, exposed to tools through their
1350
+ ``FunctionInvocationContext``.
1351
+ function_invocation_kwargs: Keyword arguments forwarded to tool invocations.
1352
+
1230
1353
  Returns:
1231
1354
  List of Copilot SDK tools.
1232
1355
  """
@@ -1236,16 +1359,33 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1236
1359
  if isinstance(tool, CopilotTool):
1237
1360
  copilot_tools.append(tool)
1238
1361
  elif isinstance(tool, FunctionTool):
1239
- copilot_tools.append(self._tool_to_copilot_tool(tool))
1362
+ copilot_tools.append(
1363
+ self._tool_to_copilot_tool(
1364
+ tool,
1365
+ agent_session=agent_session,
1366
+ function_invocation_kwargs=function_invocation_kwargs,
1367
+ )
1368
+ )
1240
1369
  elif isinstance(tool, MutableMapping):
1241
1370
  copilot_tools.append(tool) # type: ignore[arg-type]
1242
1371
  # Note: Other tool types (e.g., dict-based hosted tools) are skipped
1243
1372
 
1244
1373
  return copilot_tools
1245
1374
 
1246
- def _tool_to_copilot_tool(self, ai_func: FunctionTool) -> CopilotTool:
1375
+ def _tool_to_copilot_tool(
1376
+ self,
1377
+ ai_func: FunctionTool,
1378
+ *,
1379
+ agent_session: AgentSession | None = None,
1380
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
1381
+ ) -> CopilotTool:
1247
1382
  """Convert an FunctionTool to a Copilot SDK tool.
1248
1383
 
1384
+ Each invocation receives a ``FunctionInvocationContext`` carrying the run's
1385
+ ``function_invocation_kwargs`` and ``agent_session``, so tools that declare a
1386
+ ``FunctionInvocationContext`` parameter can read them the same way they do
1387
+ with a chat-client-backed agent.
1388
+
1249
1389
  Approval for tools declared with ``approval_mode="always_require"`` is normally
1250
1390
  enforced by the Copilot SDK's native ``on_pre_tool_use`` hook (see
1251
1391
  :meth:`_build_session_hooks`). When the deprecated ``on_function_approval``
@@ -1255,6 +1395,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1255
1395
  """
1256
1396
  approval_handler = self._function_approval_handler
1257
1397
  enforce = approval_handler is not None and ai_func.approval_mode == "always_require"
1398
+ runtime_kwargs = dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {}
1258
1399
 
1259
1400
  async def handler(invocation: ToolInvocation) -> ToolResult:
1260
1401
  args: dict[str, Any] = invocation.arguments or {}
@@ -1273,11 +1414,21 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1273
1414
  result_type="failure",
1274
1415
  error="approval_denied",
1275
1416
  )
1417
+ context = FunctionInvocationContext(
1418
+ function=ai_func,
1419
+ arguments=args,
1420
+ session=agent_session,
1421
+ kwargs=runtime_kwargs,
1422
+ )
1276
1423
  if ai_func.input_model:
1277
1424
  args_instance = ai_func.input_model(**args)
1278
- result = await ai_func.invoke(arguments=args_instance)
1425
+ result = await ai_func.invoke(
1426
+ arguments=args_instance, context=context, tool_call_id=invocation.tool_call_id or None
1427
+ )
1279
1428
  else:
1280
- result = await ai_func.invoke(arguments=args)
1429
+ result = await ai_func.invoke(
1430
+ arguments=args, context=context, tool_call_id=invocation.tool_call_id or None
1431
+ )
1281
1432
  rich = [c for c in result if c.type in ("data", "uri")]
1282
1433
  if rich:
1283
1434
  logger.warning(
@@ -1391,11 +1542,73 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1391
1542
 
1392
1543
  return {"on_pre_tool_use": default_pre_tool_use}
1393
1544
 
1545
+ def _session_run_lock(self, service_session_id: str) -> asyncio.Lock:
1546
+ """Return the lock that serializes runs on one Copilot service session."""
1547
+ key = (id(self._client), service_session_id)
1548
+ lock = _SESSION_RUN_LOCKS.get(key)
1549
+ if lock is None:
1550
+ lock = asyncio.Lock()
1551
+ _SESSION_RUN_LOCKS[key] = lock
1552
+ return lock
1553
+
1554
+ @contextlib.asynccontextmanager
1555
+ async def _session_run_scope(
1556
+ self,
1557
+ agent_session: AgentSession,
1558
+ *,
1559
+ streaming: bool,
1560
+ runtime_options: dict[str, Any],
1561
+ function_invocation_kwargs: Mapping[str, Any] | None,
1562
+ ) -> AsyncGenerator[CopilotSession]:
1563
+ """Open the Copilot session for one run and hold it exclusively until the run ends.
1564
+
1565
+ The Copilot SDK keeps a single session object per service session ID and
1566
+ re-registers tools on it on every resume, dispatching tool calls by session ID.
1567
+ Tool handlers capture the run's ``function_invocation_kwargs`` and session, so
1568
+ an overlapping resume would let one run's tool calls execute with another run's
1569
+ context. Runs on the same service session are therefore serialized.
1570
+
1571
+ Args:
1572
+ agent_session: The conversation session.
1573
+
1574
+ Keyword Args:
1575
+ streaming: Whether to enable streaming for the session.
1576
+ runtime_options: Runtime options from run that take precedence.
1577
+ function_invocation_kwargs: Keyword arguments forwarded to tool invocations.
1578
+
1579
+ Yields:
1580
+ The created or resumed CopilotSession.
1581
+ """
1582
+ lock: asyncio.Lock | None = None
1583
+ service_session_id = agent_session.service_session_id
1584
+ if isinstance(service_session_id, str):
1585
+ resume_lock = self._session_run_lock(service_session_id)
1586
+ await resume_lock.acquire()
1587
+ lock = resume_lock
1588
+ try:
1589
+ copilot_session = await self._get_or_create_session(
1590
+ agent_session,
1591
+ streaming=streaming,
1592
+ runtime_options=runtime_options,
1593
+ function_invocation_kwargs=function_invocation_kwargs,
1594
+ )
1595
+ if lock is None:
1596
+ # A newly created service session ID is not known to any other run yet,
1597
+ # so this acquire never waits.
1598
+ create_lock = self._session_run_lock(copilot_session.session_id)
1599
+ await create_lock.acquire()
1600
+ lock = create_lock
1601
+ yield copilot_session
1602
+ finally:
1603
+ if lock is not None:
1604
+ lock.release()
1605
+
1394
1606
  async def _get_or_create_session(
1395
1607
  self,
1396
1608
  agent_session: AgentSession,
1397
1609
  streaming: bool = False,
1398
1610
  runtime_options: dict[str, Any] | None = None,
1611
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
1399
1612
  ) -> CopilotSession:
1400
1613
  """Get an existing session or create a new one for the session.
1401
1614
 
@@ -1403,6 +1616,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1403
1616
  agent_session: The conversation session.
1404
1617
  streaming: Whether to enable streaming for the session.
1405
1618
  runtime_options: Runtime options from run that take precedence.
1619
+ function_invocation_kwargs: Keyword arguments forwarded to tool invocations.
1406
1620
 
1407
1621
  Returns:
1408
1622
  A CopilotSession instance.
@@ -1420,18 +1634,53 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1420
1634
  raise AgentException(
1421
1635
  "GitHubCopilotAgent expects a string service_session_id for session resumption."
1422
1636
  )
1423
- return await self._resume_session(service_session_id, streaming, runtime_options)
1637
+ return await self._resume_session(
1638
+ service_session_id,
1639
+ streaming,
1640
+ runtime_options,
1641
+ agent_session=agent_session,
1642
+ function_invocation_kwargs=function_invocation_kwargs,
1643
+ )
1424
1644
 
1425
- session = await self._create_session(streaming, runtime_options)
1645
+ session = await self._create_session(
1646
+ streaming,
1647
+ runtime_options,
1648
+ agent_session=agent_session,
1649
+ function_invocation_kwargs=function_invocation_kwargs,
1650
+ )
1426
1651
  agent_session.service_session_id = session.session_id
1427
1652
  return session
1428
1653
  except Exception as ex:
1429
1654
  raise AgentException(f"Failed to create GitHub Copilot session: {ex}") from ex
1430
1655
 
1656
+ def _warn_once_about_unloaded_file_hooks(self, session_working_directory: str | None) -> None:
1657
+ """Warn once when the effective working directory defines file hooks that will not run.
1658
+
1659
+ Without this the default is silent: hooks simply stop running, with nothing to
1660
+ point at the cause. The warning fires at most once per agent so a long-lived agent
1661
+ does not repeat it on every run.
1662
+ """
1663
+ if self._file_hooks_warning_emitted:
1664
+ return
1665
+ working_directory = _resolve_effective_working_directory(session_working_directory, self._client)
1666
+ if not _has_file_hooks(working_directory):
1667
+ return
1668
+ self._file_hooks_warning_emitted = True
1669
+ logger.warning(
1670
+ "Not loading the file hooks defined in '%s': GitHubCopilotAgent leaves "
1671
+ "enable_file_hooks off so a session behaves the same way in every working "
1672
+ "directory. Set enable_file_hooks=True in default_options (or in per-run "
1673
+ "options) to run them.",
1674
+ working_directory / ".github" / "hooks",
1675
+ )
1676
+
1431
1677
  def _build_session_kwargs(
1432
1678
  self,
1433
1679
  streaming: bool,
1434
1680
  runtime_options: dict[str, Any] | None,
1681
+ *,
1682
+ agent_session: AgentSession | None = None,
1683
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
1435
1684
  ) -> dict[str, Any]:
1436
1685
  """Assemble keyword arguments for ``create_session`` / ``resume_session``.
1437
1686
 
@@ -1439,16 +1688,22 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1439
1688
  ``runtime_options`` which override them. Every key is forwarded verbatim to
1440
1689
  the Copilot SDK, so any ``create_session`` parameter is supported without a
1441
1690
  dedicated mapping here (an unknown name surfaces as a ``TypeError`` from the
1442
- SDK). A few keys are handled specially because they need a secure default
1443
- (``on_permission_request`` defaults to denying all requests, and is wrapped so
1444
- under-specified ``approve-for-session`` decisions are scoped to the request that
1445
- triggered them) or transforming: ``tools`` are merged with the agent's tools and
1446
- converted to SDK tools, and approval callbacks are turned into ``hooks``.
1691
+ SDK). A few keys are handled specially because they need a specific default or
1692
+ transforming: ``on_permission_request`` defaults to denying all requests and is
1693
+ wrapped so under-specified ``approve-for-session`` decisions are scoped to the
1694
+ request that triggered them, ``enable_file_hooks`` defaults to off, ``tools`` are
1695
+ merged with the agent's tools and converted to SDK tools, and approval callbacks
1696
+ are turned into ``hooks``.
1447
1697
 
1448
1698
  Args:
1449
1699
  streaming: Whether to enable streaming for the session.
1450
1700
  runtime_options: Runtime options that take precedence over default_options.
1451
1701
 
1702
+ Keyword Args:
1703
+ agent_session: The conversation session, exposed to tools through their
1704
+ ``FunctionInvocationContext``.
1705
+ function_invocation_kwargs: Keyword arguments forwarded to tool invocations.
1706
+
1452
1707
  Returns:
1453
1708
  The keyword arguments to splat into the SDK session factory.
1454
1709
  """
@@ -1459,8 +1714,19 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1459
1714
 
1460
1715
  # Merge agent-level tools with any caller-supplied tools (from default_options
1461
1716
  # or per-run options, the latter winning) and convert to SDK tools.
1462
- all_tools = list(self._tools or []) + list(kwargs.get("tools") or [])
1463
- kwargs["tools"] = self._prepare_tools(all_tools) if all_tools else None
1717
+ # Normalize the option-supplied tools the way the constructor does: it converts callables
1718
+ # and flattens tool-collection wrappers, which can otherwise hide an MCPTool.
1719
+ all_tools = normalize_tools(list(self._tools or []) + list(kwargs.get("tools") or []))
1720
+ _reject_mcp_tools(all_tools)
1721
+ kwargs["tools"] = (
1722
+ self._prepare_tools(
1723
+ all_tools,
1724
+ agent_session=agent_session,
1725
+ function_invocation_kwargs=function_invocation_kwargs,
1726
+ )
1727
+ if all_tools
1728
+ else None
1729
+ )
1464
1730
 
1465
1731
  kwargs["streaming"] = streaming
1466
1732
  # model may already be present from per-run options (merged above); otherwise fall
@@ -1468,8 +1734,17 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1468
1734
  if not kwargs.get("model"):
1469
1735
  kwargs["model"] = self._settings.get("model") or None
1470
1736
  kwargs["on_permission_request"] = _with_normalized_permission_decisions(
1471
- opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
1737
+ cast(
1738
+ PermissionHandlerType,
1739
+ opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions,
1740
+ )
1472
1741
  )
1742
+ # File hooks let the working directory's checked-in configuration influence what the
1743
+ # CLI does on the host, so the agent leaves them off for a consistent session in every
1744
+ # checkout. Callers opt in through ``default_options`` or per-run options.
1745
+ if kwargs.get("enable_file_hooks") is None:
1746
+ kwargs["enable_file_hooks"] = False
1747
+ self._warn_once_about_unloaded_file_hooks(kwargs.get("working_directory"))
1473
1748
  kwargs["hooks"] = self._build_session_hooks(all_tools, kwargs)
1474
1749
 
1475
1750
  # Strip agent-internal and client-level keys that are consumed here or in the
@@ -1492,23 +1767,40 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1492
1767
  self,
1493
1768
  streaming: bool,
1494
1769
  runtime_options: dict[str, Any] | None = None,
1770
+ *,
1771
+ agent_session: AgentSession | None = None,
1772
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
1495
1773
  ) -> CopilotSession:
1496
1774
  """Create a new Copilot session.
1497
1775
 
1498
1776
  Args:
1499
1777
  streaming: Whether to enable streaming for the session.
1500
1778
  runtime_options: Runtime options that take precedence over default_options.
1779
+
1780
+ Keyword Args:
1781
+ agent_session: The conversation session, exposed to tools.
1782
+ function_invocation_kwargs: Keyword arguments forwarded to tool invocations.
1501
1783
  """
1502
1784
  if not self._client:
1503
1785
  raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
1504
1786
 
1505
- return await self._client.create_session(**self._build_session_kwargs(streaming, runtime_options))
1787
+ return await self._client.create_session(
1788
+ **self._build_session_kwargs(
1789
+ streaming,
1790
+ runtime_options,
1791
+ agent_session=agent_session,
1792
+ function_invocation_kwargs=function_invocation_kwargs,
1793
+ )
1794
+ )
1506
1795
 
1507
1796
  async def _resume_session(
1508
1797
  self,
1509
1798
  session_id: str,
1510
1799
  streaming: bool,
1511
1800
  runtime_options: dict[str, Any] | None = None,
1801
+ *,
1802
+ agent_session: AgentSession | None = None,
1803
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
1512
1804
  ) -> CopilotSession:
1513
1805
  """Resume an existing Copilot session by ID.
1514
1806
 
@@ -1516,11 +1808,23 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
1516
1808
  session_id: The session ID to resume.
1517
1809
  streaming: Whether to enable streaming for the session.
1518
1810
  runtime_options: Runtime options that take precedence over default_options.
1811
+
1812
+ Keyword Args:
1813
+ agent_session: The conversation session, exposed to tools.
1814
+ function_invocation_kwargs: Keyword arguments forwarded to tool invocations.
1519
1815
  """
1520
1816
  if not self._client:
1521
1817
  raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
1522
1818
 
1523
- return await self._client.resume_session(session_id, **self._build_session_kwargs(streaming, runtime_options))
1819
+ return await self._client.resume_session(
1820
+ session_id,
1821
+ **self._build_session_kwargs(
1822
+ streaming,
1823
+ runtime_options,
1824
+ agent_session=agent_session,
1825
+ function_invocation_kwargs=function_invocation_kwargs,
1826
+ ),
1827
+ )
1524
1828
 
1525
1829
 
1526
1830
  class GitHubCopilotAgent( # type: ignore[misc]
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
4
4
  authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
- version = "1.0.3"
7
+ version = "2.0.1"
8
8
  license-files = ["LICENSE"]
9
9
  urls.homepage = "https://aka.ms/agent-framework"
10
10
  urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,11 +23,11 @@ classifiers = [
23
23
  ]
24
24
  dependencies = [
25
25
  "agent-framework-core>=1.15.0,<2",
26
- "github-copilot-sdk==1.0.2; python_version >= '3.11'",
26
+ "github-copilot-sdk==1.0.11; python_version >= '3.11'",
27
27
  ]
28
28
 
29
29
  [tool.uv]
30
- prerelease = "if-necessary-or-explicit"
30
+ prerelease = "if-necessary"
31
31
  environments = [
32
32
  "sys_platform == 'darwin'",
33
33
  "sys_platform == 'linux'",