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.
- azure_functions_agents/__init__.py +20 -0
- azure_functions_agents/app.py +720 -0
- azure_functions_agents/arm.py +95 -0
- azure_functions_agents/client_manager.py +84 -0
- azure_functions_agents/config.py +191 -0
- azure_functions_agents/connector_tool_cache.py +124 -0
- azure_functions_agents/connector_tools.py +267 -0
- azure_functions_agents/connectors.py +460 -0
- azure_functions_agents/mcp.py +87 -0
- azure_functions_agents/public/index.html +1504 -0
- azure_functions_agents/runner.py +406 -0
- azure_functions_agents/sandbox.py +288 -0
- azure_functions_agents/skills.py +24 -0
- azure_functions_agents/tools.py +316 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/METADATA +386 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/RECORD +20 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/WHEEL +5 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/licenses/LICENSE.md +21 -0
- azurefunctions_agents_runtime-0.0.0.dev1.dist-info/top_level.txt +2 -0
- copilot_functions/__init__.py +3 -0
|
@@ -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()
|