azurefunctions-agents-runtime 0.0.0.dev1__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,95 @@
1
+ import asyncio
2
+
3
+ import aiohttp
4
+ from azure.identity import DefaultAzureCredential
5
+
6
+ ARM_BASE = "https://management.azure.com"
7
+ DEFAULT_API_VERSION = "2016-06-01"
8
+
9
+
10
+ class ArmClient:
11
+ def __init__(self):
12
+ self._credential = DefaultAzureCredential()
13
+ self._session: aiohttp.ClientSession | None = None
14
+
15
+ async def _ensure_session(self) -> aiohttp.ClientSession:
16
+ if self._session is None or self._session.closed:
17
+ self._session = aiohttp.ClientSession()
18
+ return self._session
19
+
20
+ async def _get_token(self) -> str:
21
+ token = await asyncio.to_thread(
22
+ self._credential.get_token, "https://management.azure.com/.default"
23
+ )
24
+ return token.token
25
+
26
+ async def get(self, path: str, *, api_version: str = DEFAULT_API_VERSION, params: dict | None = None) -> dict:
27
+ session = await self._ensure_session()
28
+ url = f"{ARM_BASE}{path}"
29
+ query = {"api-version": api_version}
30
+ if params:
31
+ query.update(params)
32
+ headers = {"Authorization": f"Bearer {await self._get_token()}"}
33
+ async with session.get(url, headers=headers, params=query) as resp:
34
+ resp.raise_for_status()
35
+ return await resp.json()
36
+
37
+ async def post(self, path: str, body: dict | None = None, *, api_version: str = DEFAULT_API_VERSION) -> dict:
38
+ session = await self._ensure_session()
39
+ url = f"{ARM_BASE}{path}"
40
+ query = {"api-version": api_version}
41
+ headers = {"Authorization": f"Bearer {await self._get_token()}"}
42
+ async with session.post(url, headers=headers, params=query, json=body) as resp:
43
+ resp.raise_for_status()
44
+ return await resp.json()
45
+
46
+ async def close(self):
47
+ if self._session and not self._session.closed:
48
+ await self._session.close()
49
+ self._credential.close()
50
+
51
+
52
+ class DataPlaneClient:
53
+ """HTTP client for connector data plane invocation (V2 / AI Gateway).
54
+
55
+ Uses ``https://apihub.azure.com/.default`` token scope instead of
56
+ the ARM management plane scope.
57
+ """
58
+
59
+ def __init__(self):
60
+ self._credential = DefaultAzureCredential()
61
+ self._session: aiohttp.ClientSession | None = None
62
+
63
+ async def _ensure_session(self) -> aiohttp.ClientSession:
64
+ if self._session is None or self._session.closed:
65
+ self._session = aiohttp.ClientSession()
66
+ return self._session
67
+
68
+ async def _get_token(self) -> str:
69
+ token = await asyncio.to_thread(
70
+ self._credential.get_token, "https://apihub.azure.com/.default"
71
+ )
72
+ return token.token
73
+
74
+ async def request(
75
+ self,
76
+ method: str,
77
+ url: str,
78
+ *,
79
+ body: dict | None = None,
80
+ params: dict | None = None,
81
+ ) -> dict:
82
+ session = await self._ensure_session()
83
+ headers = {"Authorization": f"Bearer {await self._get_token()}"}
84
+ async with session.request(
85
+ method, url, headers=headers, params=params, json=body
86
+ ) as resp:
87
+ resp.raise_for_status()
88
+ if resp.content_length == 0:
89
+ return {}
90
+ return await resp.json()
91
+
92
+ async def close(self):
93
+ if self._session and not self._session.closed:
94
+ await self._session.close()
95
+ self._credential.close()
@@ -0,0 +1,84 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ from typing import Optional
5
+
6
+ from copilot import CopilotClient, SubprocessConfig
7
+
8
+ from .config import get_app_root, resolve_config_dir
9
+
10
+
11
+ def _is_byok_mode() -> bool:
12
+ """Check if BYO key (Microsoft Foundry) environment variables are configured."""
13
+ return bool(
14
+ os.environ.get("AZURE_AI_FOUNDRY_ENDPOINT")
15
+ and os.environ.get("AZURE_AI_FOUNDRY_API_KEY")
16
+ )
17
+
18
+
19
+ class CopilotClientManager:
20
+ """
21
+ Singleton manager for the CopilotClient.
22
+ """
23
+
24
+ _instance: Optional["CopilotClientManager"] = None
25
+ _client: Optional[CopilotClient] = None
26
+ _lock: asyncio.Lock = None
27
+ _started: bool = False
28
+
29
+ def __new__(cls):
30
+ if cls._instance is None:
31
+ cls._instance = super().__new__(cls)
32
+ cls._lock = asyncio.Lock()
33
+ return cls._instance
34
+
35
+ @classmethod
36
+ async def get_client(cls) -> CopilotClient:
37
+ manager = cls()
38
+ async with manager._lock:
39
+ if manager._client is None or not manager._started:
40
+ app_root = str(get_app_root())
41
+ config_dir = resolve_config_dir()
42
+
43
+ # Pass --config-dir as a CLI startup flag so the subprocess
44
+ # writes events.jsonl and can load sessions from the shared
45
+ # mount, enabling cross-instance session resume.
46
+ cli_args: list[str] = []
47
+ if config_dir:
48
+ cli_args = ["--config-dir", config_dir]
49
+ logging.info(f"CLI config-dir: {config_dir}")
50
+
51
+ if _is_byok_mode():
52
+ logging.info("BYOK mode: using Microsoft Foundry (no GitHub token)")
53
+ manager._client = CopilotClient(
54
+ SubprocessConfig(cwd=app_root, cli_args=cli_args)
55
+ )
56
+ else:
57
+ github_token = os.environ.get("GITHUB_TOKEN")
58
+ manager._client = CopilotClient(
59
+ SubprocessConfig(
60
+ github_token=github_token,
61
+ cwd=app_root,
62
+ cli_args=cli_args,
63
+ )
64
+ )
65
+
66
+ await manager._client.start()
67
+ manager._started = True
68
+ logging.info(f"CopilotClient singleton started (BYOK: {_is_byok_mode()})")
69
+ return manager._client
70
+
71
+ @classmethod
72
+ async def shutdown(cls):
73
+ manager = cls()
74
+ async with manager._lock:
75
+ if manager._client and manager._started:
76
+ await manager._client.stop()
77
+ manager._started = False
78
+ manager._client = None
79
+ logging.info("CopilotClient singleton stopped")
80
+
81
+ @classmethod
82
+ def is_running(cls) -> bool:
83
+ manager = cls()
84
+ return manager._started
@@ -0,0 +1,191 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Application root resolution
10
+ # ---------------------------------------------------------------------------
11
+
12
+ _app_root: Optional[Path] = None
13
+
14
+
15
+ def set_app_root(path: Path) -> None:
16
+ """Explicitly set the application root directory.
17
+
18
+ Call this early (e.g. before ``create_function_app()``) so that all
19
+ agent, tool, skill, and MCP discovery uses the correct base path.
20
+ """
21
+ global _app_root
22
+ _app_root = Path(path).resolve()
23
+
24
+
25
+ def get_app_root() -> Path:
26
+ """Return the root directory of the user's agent project.
27
+
28
+ This is the directory containing ``main.agent.md``, ``tools/``,
29
+ ``.vscode/mcp.json``, skills directories, etc.
30
+
31
+ Resolution order:
32
+
33
+ 1. Value set via ``set_app_root()``
34
+ 2. ``COPILOT_APP_ROOT`` environment variable
35
+ 3. ``AzureWebJobsScriptRoot`` environment variable (set automatically
36
+ by the Azure Functions host, both locally via ``func start`` and
37
+ in Azure — points to the directory containing ``host.json``)
38
+ 4. Current working directory (``Path.cwd()``)
39
+ """
40
+ if _app_root is not None:
41
+ return _app_root
42
+ explicit = os.environ.get("COPILOT_APP_ROOT")
43
+ if explicit:
44
+ return Path(explicit).resolve()
45
+ script_root = os.environ.get("AzureWebJobsScriptRoot")
46
+ if script_root:
47
+ return Path(script_root).resolve()
48
+ return Path.cwd().resolve()
49
+
50
+ # Default session state directory used by the Copilot CLI
51
+ _DEFAULT_CONFIG_DIR = os.path.expanduser("~/.copilot")
52
+ _REMOTE_CONFIG_DIR = "/code-assistant-session"
53
+
54
+
55
+ def resolve_config_dir() -> Optional[str]:
56
+ """
57
+ Resolve the config directory for session state persistence.
58
+
59
+ Priority:
60
+ 1. CODE_ASSISTANT_CONFIG_PATH env var (explicit override)
61
+ 2. CONTAINER_NAME env var is set → /code-assistant-session (remote/Azure Functions mode)
62
+ 3. Neither set → None (SDK default ~/.copilot/ is used)
63
+ """
64
+ explicit_path = os.environ.get("CODE_ASSISTANT_CONFIG_PATH")
65
+ if explicit_path:
66
+ logging.info(f"Using CODE_ASSISTANT_CONFIG_PATH: {explicit_path}")
67
+ return explicit_path
68
+
69
+ container_name = os.environ.get("CONTAINER_NAME")
70
+ if container_name:
71
+ logging.info(f"Remote mode detected (CONTAINER_NAME={container_name}), using {_REMOTE_CONFIG_DIR}")
72
+ return _REMOTE_CONFIG_DIR
73
+
74
+ return None
75
+
76
+
77
+ def session_exists(config_dir: Optional[str], session_id: str) -> bool:
78
+ """
79
+ Check if a session exists on disk by looking for its directory.
80
+
81
+ Session state is stored under {config_dir}/session-state/{sessionId}/.
82
+ Falls back to ~/.copilot/session-state/{sessionId}/ if config_dir is None.
83
+ """
84
+ base = config_dir if config_dir else _DEFAULT_CONFIG_DIR
85
+ session_path = os.path.join(base, "session-state", session_id)
86
+ exists = os.path.isdir(session_path)
87
+ logging.info(f"Session '{session_id}' exists at {session_path}: {exists}")
88
+ return exists
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Environment variable substitution for agent frontmatter values
93
+ # ---------------------------------------------------------------------------
94
+
95
+ _PERCENT_PATTERN = re.compile(r"^%([^%]+)%$")
96
+ _DOLLAR_PATTERN = re.compile(r"^\$([A-Za-z_][A-Za-z0-9_]*)$")
97
+
98
+
99
+ def resolve_env_var(value: str) -> str:
100
+ """Resolve a frontmatter value that is a single env-var reference.
101
+
102
+ Supported syntaxes (full-string match only — partial substitution
103
+ such as ``prefix$VAR`` is intentionally **not** supported):
104
+
105
+ - ``%VAR_NAME%`` — value is entirely ``%…%``
106
+ - ``$VAR_NAME`` — value is entirely ``$IDENT``
107
+
108
+ If the value does not match either pattern, or the referenced
109
+ environment variable is not set, the original string is returned
110
+ unchanged.
111
+
112
+ The following agent frontmatter fields are resolved through
113
+ this function (all represent external resource identifiers or
114
+ endpoints):
115
+
116
+ - ``trigger.*`` (all string values except ``type``)
117
+ - ``tools_from_connections[].connection_id``
118
+ - ``execution_sandbox.session_pool_management_endpoint``
119
+
120
+ Fields that should **not** use substitution (identifiers, literals,
121
+ or user-facing text): ``name``, ``description``, ``trigger.type``,
122
+ ``logger``.
123
+ """
124
+ stripped = value.strip()
125
+ m = _PERCENT_PATTERN.match(stripped) or _DOLLAR_PATTERN.match(stripped)
126
+ if m:
127
+ return os.environ.get(m.group(1), value)
128
+ return value
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Boolean coercion helper
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def _to_bool(value: Any, default: bool = True) -> bool:
137
+ """Coerce a frontmatter value to bool."""
138
+ if isinstance(value, bool):
139
+ return value
140
+ if isinstance(value, str):
141
+ lowered = value.strip().lower()
142
+ if lowered in {"true", "1", "yes", "y"}:
143
+ return True
144
+ if lowered in {"false", "0", "no", "n"}:
145
+ return False
146
+ return default
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Inline environment variable substitution for agent markdown body text
151
+ # ---------------------------------------------------------------------------
152
+
153
+ _INLINE_DOLLAR_PATTERN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
154
+ _INLINE_PERCENT_PATTERN = re.compile(r"%([A-Za-z_][A-Za-z0-9_]*)%")
155
+
156
+
157
+ def substitute_env_vars_in_text(text: str) -> str:
158
+ """Perform inline environment variable substitution in free-form text.
159
+
160
+ Unlike :func:`resolve_env_var` (which requires the *entire* string to
161
+ be a single variable reference), this function replaces variable
162
+ references **inline** within arbitrary text.
163
+
164
+ Supported syntaxes:
165
+
166
+ - ``$VAR_NAME`` — e.g. ``send mail to $TO_EMAIL``
167
+ - ``%VAR_NAME%`` — e.g. ``post to the %TEAM_NAME% team``
168
+
169
+ If the referenced environment variable is not set, the original
170
+ reference is left unchanged (fail-open).
171
+
172
+ Text inside fenced code blocks (``````...``````) is left untouched
173
+ so that documentation examples are not accidentally altered.
174
+ """
175
+
176
+ def _dollar_replacer(m: re.Match) -> str:
177
+ return os.environ.get(m.group(1), m.group(0))
178
+
179
+ def _percent_replacer(m: re.Match) -> str:
180
+ return os.environ.get(m.group(1), m.group(0))
181
+
182
+ def _substitute(segment: str) -> str:
183
+ segment = _INLINE_DOLLAR_PATTERN.sub(_dollar_replacer, segment)
184
+ segment = _INLINE_PERCENT_PATTERN.sub(_percent_replacer, segment)
185
+ return segment
186
+
187
+ # Split on fenced code blocks (```); odd-indexed parts are code blocks
188
+ parts = text.split("```")
189
+ for i in range(0, len(parts), 2):
190
+ parts[i] = _substitute(parts[i])
191
+ return "```".join(parts)
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any, Dict, List
6
+
7
+ from .arm import ArmClient, DataPlaneClient
8
+ from .config import resolve_env_var
9
+ from .connectors import load_connection, is_v2_connection
10
+ from .connector_tools import generate_tools
11
+
12
+
13
+ class _ConnectorToolCache:
14
+ """Lazy-init singleton cache for connector tools discovered from ARM API."""
15
+
16
+ def __init__(self):
17
+ self._tools: list | None = None
18
+ self._arm: ArmClient | None = None
19
+ self._data_plane: DataPlaneClient | None = None
20
+ self._lock = asyncio.Lock()
21
+ self._connection_specs: List[Dict[str, Any]] = []
22
+
23
+ def add_connection_specs(self, specs: List[Dict[str, Any]]) -> None:
24
+ """Append tools_from_connections specs from an agent file.
25
+
26
+ Deduplicates by resolved connection_id so the same connector
27
+ isn't loaded twice even if referenced from multiple agents.
28
+ """
29
+ if not specs:
30
+ return
31
+ existing_ids = {
32
+ resolve_env_var(str(s.get("connection_id", "")))
33
+ for s in self._connection_specs
34
+ }
35
+ for spec in specs:
36
+ cid = resolve_env_var(str(spec.get("connection_id", "")))
37
+ if cid and cid not in existing_ids:
38
+ self._connection_specs.append(spec)
39
+ existing_ids.add(cid)
40
+
41
+ async def get_tools(self) -> list:
42
+ """Return cached connector tools, discovering them on first call."""
43
+ if self._tools is not None:
44
+ return self._tools
45
+
46
+ async with self._lock:
47
+ # Double-check after acquiring lock
48
+ if self._tools is not None:
49
+ return self._tools
50
+
51
+ if not self._connection_specs:
52
+ self._tools = []
53
+ return self._tools
54
+
55
+ self._arm = ArmClient()
56
+ all_tools = []
57
+
58
+ # Check if any V2 connections need a data plane client
59
+ has_v2 = any(
60
+ is_v2_connection(resolve_env_var(str(s.get("connection_id", ""))))
61
+ for s in self._connection_specs
62
+ )
63
+ if has_v2:
64
+ self._data_plane = DataPlaneClient()
65
+
66
+ for spec in self._connection_specs:
67
+ raw_connection_id = spec.get("connection_id", "")
68
+ if not raw_connection_id:
69
+ logging.warning("tools_from_connections entry missing 'connection_id', skipping")
70
+ continue
71
+
72
+ connection_id = resolve_env_var(str(raw_connection_id))
73
+ if not connection_id or connection_id.startswith("%") or connection_id.startswith("$"):
74
+ logging.warning(f"tools_from_connections: could not resolve connection_id '{raw_connection_id}', skipping")
75
+ continue
76
+
77
+ try:
78
+ v2 = is_v2_connection(connection_id)
79
+ connection = await load_connection(
80
+ self._arm, connection_id,
81
+ data_plane_client=self._data_plane if v2 else None,
82
+ )
83
+
84
+ # Determine tool name prefix: explicit > connection name > api_name
85
+ prefix = spec.get("prefix")
86
+ if isinstance(prefix, str) and prefix.strip():
87
+ prefix = prefix.strip()
88
+ else:
89
+ prefix = None # generate_tools will use connection.name
90
+
91
+ tools = generate_tools(
92
+ self._arm, connection, prefix=prefix,
93
+ data_plane_client=self._data_plane if v2 else None,
94
+ )
95
+ all_tools.extend(tools)
96
+ version_label = "V2" if v2 else "V1"
97
+ logging.info(
98
+ f"Connector tools discovered ({version_label}): {connection.display_name} ({connection.api_name}): "
99
+ f"{len(tools)} tools [{connection.status}]"
100
+ )
101
+ for tool in tools:
102
+ logging.info(f" - {tool.name}: {tool.description[:100]}")
103
+ except Exception as e:
104
+ logging.warning(f"Failed to load connector tools for '{connection_id}': {e}")
105
+
106
+ self._tools = all_tools
107
+ return self._tools
108
+
109
+
110
+ _cache = _ConnectorToolCache()
111
+
112
+
113
+ def configure_connector_tools(tools_from_connections: List[Dict[str, Any]]) -> None:
114
+ """Add connector tool specs from an agent file to the global cache.
115
+
116
+ Can be called multiple times (once per agent file). Specs are
117
+ deduplicated by connection_id so the same connector isn't loaded twice.
118
+ """
119
+ _cache.add_connection_specs(tools_from_connections)
120
+
121
+
122
+ async def get_connector_tools() -> list:
123
+ """Get cached connector tools (lazy-discovers on first call)."""
124
+ return await _cache.get_tools()