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,24 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .config import get_app_root
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_session_directory_for_skills() -> Optional[str]:
|
|
9
|
+
"""
|
|
10
|
+
Resolve the skills directory at {app_root}/skills.
|
|
11
|
+
"""
|
|
12
|
+
app_root = str(get_app_root())
|
|
13
|
+
env_session_dir = os.environ.get("COPILOT_SESSION_DIRECTORY")
|
|
14
|
+
if env_session_dir:
|
|
15
|
+
resolved = os.path.expanduser(env_session_dir)
|
|
16
|
+
if os.path.isdir(resolved):
|
|
17
|
+
return resolved
|
|
18
|
+
|
|
19
|
+
for name in ("skills", "Skills"):
|
|
20
|
+
skill_path = os.path.join(app_root, name)
|
|
21
|
+
if os.path.isdir(skill_path):
|
|
22
|
+
return skill_path
|
|
23
|
+
|
|
24
|
+
return None
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Callable, List, Optional
|
|
10
|
+
|
|
11
|
+
from copilot import define_tool
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from .config import get_app_root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def discover_tools() -> List[Callable]:
|
|
18
|
+
"""
|
|
19
|
+
Dynamically discover and load tools from the `tools` folder.
|
|
20
|
+
"""
|
|
21
|
+
tools: List[Callable] = []
|
|
22
|
+
project_src_dir = str(get_app_root())
|
|
23
|
+
tools_dir = os.path.join(project_src_dir, "tools")
|
|
24
|
+
|
|
25
|
+
# Add tools dir to sys.path so tool modules can import shared helpers
|
|
26
|
+
# (e.g. _patterns.py, _utils.py — files prefixed with _ that are skipped
|
|
27
|
+
# during tool registration but may be imported by tool modules)
|
|
28
|
+
if tools_dir not in sys.path:
|
|
29
|
+
sys.path.insert(0, tools_dir)
|
|
30
|
+
|
|
31
|
+
print(f"[Tool Discovery] Looking for tools in: {tools_dir}")
|
|
32
|
+
print(f"[Tool Discovery] Directory exists: {os.path.exists(tools_dir)}")
|
|
33
|
+
|
|
34
|
+
if not os.path.exists(tools_dir):
|
|
35
|
+
print(f"[Tool Discovery] WARNING: Tools directory not found: {tools_dir}")
|
|
36
|
+
return tools
|
|
37
|
+
|
|
38
|
+
files = [f for f in os.listdir(tools_dir) if f.endswith(".py") and not f.startswith("_")]
|
|
39
|
+
print(f"[Tool Discovery] Python files found: {files}")
|
|
40
|
+
|
|
41
|
+
for filename in files:
|
|
42
|
+
filepath = os.path.join(tools_dir, filename)
|
|
43
|
+
module_name = filename[:-3]
|
|
44
|
+
print(f"[Tool Discovery] Loading module: {module_name} from {filepath}")
|
|
45
|
+
try:
|
|
46
|
+
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
|
47
|
+
if spec is None or spec.loader is None:
|
|
48
|
+
print(f"[Tool Discovery] ERROR: Could not create spec for {filename}")
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
module = importlib.util.module_from_spec(spec)
|
|
52
|
+
spec.loader.exec_module(module)
|
|
53
|
+
|
|
54
|
+
members = inspect.getmembers(module, inspect.isfunction)
|
|
55
|
+
local_functions = [
|
|
56
|
+
(name, obj)
|
|
57
|
+
for name, obj in members
|
|
58
|
+
if obj.__module__ == module_name and not name.startswith("_")
|
|
59
|
+
]
|
|
60
|
+
print(f"[Tool Discovery] Local functions in {filename}: {[m[0] for m in local_functions]}")
|
|
61
|
+
|
|
62
|
+
for name, obj in local_functions:
|
|
63
|
+
description = (obj.__doc__ or f"Tool: {name}").strip()
|
|
64
|
+
tools.append(define_tool(description=description)(obj))
|
|
65
|
+
print(f"[Tool Discovery] Loaded: {name}")
|
|
66
|
+
print(f"[Tool Discovery] Description: {description}")
|
|
67
|
+
break
|
|
68
|
+
except Exception as e:
|
|
69
|
+
import traceback
|
|
70
|
+
|
|
71
|
+
print(f"[Tool Discovery] ERROR loading {filename}: {e}")
|
|
72
|
+
traceback.print_exc()
|
|
73
|
+
logging.error(f"Failed to load tool from {filename}: {e}")
|
|
74
|
+
|
|
75
|
+
return tools
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Built-in tools (always available, shipped with the library)
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
# Directories the agent is allowed to read from.
|
|
83
|
+
_ALLOWED_READ_DIRS = [
|
|
84
|
+
os.path.normpath(tempfile.gettempdir()),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
# Allow reading skill reference files from {approot}/skills/
|
|
88
|
+
_skills_dir = os.path.join(str(get_app_root()), "skills")
|
|
89
|
+
if os.path.isdir(_skills_dir):
|
|
90
|
+
_ALLOWED_READ_DIRS.append(os.path.normpath(_skills_dir))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _check_access(path: str) -> Optional[str]:
|
|
94
|
+
"""Return an error JSON string if the path is not allowed, else None."""
|
|
95
|
+
requested = os.path.normpath(path)
|
|
96
|
+
allowed = any(
|
|
97
|
+
requested.startswith(d + os.sep) or requested == d
|
|
98
|
+
for d in _ALLOWED_READ_DIRS
|
|
99
|
+
)
|
|
100
|
+
if not allowed:
|
|
101
|
+
return json.dumps({"error": "Access denied: path is not in an allowed directory"})
|
|
102
|
+
if not os.path.isfile(requested):
|
|
103
|
+
return json.dumps({"error": f"File not found: {path}"})
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_lines(path: str) -> List[str]:
|
|
108
|
+
"""Read all lines from a file."""
|
|
109
|
+
with open(os.path.normpath(path), "r", encoding="utf-8", errors="replace") as f:
|
|
110
|
+
return f.readlines()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# -- view (read file with optional line range) -----------------------------
|
|
114
|
+
|
|
115
|
+
class ViewParams(BaseModel):
|
|
116
|
+
path: str = Field(description="Absolute path to the file to read")
|
|
117
|
+
start_line: Optional[int] = Field(default=None, description="1-based start line number. If omitted, reads from the beginning.")
|
|
118
|
+
end_line: Optional[int] = Field(default=None, description="1-based end line number (inclusive). If omitted, reads to the end.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@define_tool(
|
|
122
|
+
description=(
|
|
123
|
+
"View a file on the local system by absolute path. Use view_range"
|
|
124
|
+
" (start_line/end_line) to read specific sections. Use this to read"
|
|
125
|
+
" files that other tools have saved to the temp directory."
|
|
126
|
+
),
|
|
127
|
+
overrides_built_in_tool=True,
|
|
128
|
+
)
|
|
129
|
+
async def view(params: ViewParams) -> str:
|
|
130
|
+
err = _check_access(params.path)
|
|
131
|
+
if err:
|
|
132
|
+
return err
|
|
133
|
+
|
|
134
|
+
lines = _read_lines(params.path)
|
|
135
|
+
total = len(lines)
|
|
136
|
+
start = (params.start_line or 1) - 1
|
|
137
|
+
end = params.end_line or total
|
|
138
|
+
start = max(0, min(start, total))
|
|
139
|
+
end = max(start, min(end, total))
|
|
140
|
+
|
|
141
|
+
return json.dumps({
|
|
142
|
+
"total_lines": total,
|
|
143
|
+
"start_line": start + 1,
|
|
144
|
+
"end_line": end,
|
|
145
|
+
"content": "".join(lines[start:end]),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# -- head (first N lines) -------------------------------------------------
|
|
150
|
+
|
|
151
|
+
class HeadParams(BaseModel):
|
|
152
|
+
path: str = Field(description="Absolute path to the file")
|
|
153
|
+
lines: Optional[int] = Field(default=10, description="Number of lines to return from the start (default 10)")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@define_tool(description="Show the first N lines of a file on the local system (default 10).")
|
|
157
|
+
async def head(params: HeadParams) -> str:
|
|
158
|
+
err = _check_access(params.path)
|
|
159
|
+
if err:
|
|
160
|
+
return err
|
|
161
|
+
|
|
162
|
+
all_lines = _read_lines(params.path)
|
|
163
|
+
n = max(1, params.lines or 10)
|
|
164
|
+
return json.dumps({
|
|
165
|
+
"total_lines": len(all_lines),
|
|
166
|
+
"lines_returned": min(n, len(all_lines)),
|
|
167
|
+
"content": "".join(all_lines[:n]),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# -- tail (last N lines) --------------------------------------------------
|
|
172
|
+
|
|
173
|
+
class TailParams(BaseModel):
|
|
174
|
+
path: str = Field(description="Absolute path to the file")
|
|
175
|
+
lines: Optional[int] = Field(default=10, description="Number of lines to return from the end (default 10)")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@define_tool(description="Show the last N lines of a file on the local system (default 10).")
|
|
179
|
+
async def tail(params: TailParams) -> str:
|
|
180
|
+
err = _check_access(params.path)
|
|
181
|
+
if err:
|
|
182
|
+
return err
|
|
183
|
+
|
|
184
|
+
all_lines = _read_lines(params.path)
|
|
185
|
+
n = max(1, params.lines or 10)
|
|
186
|
+
selected = all_lines[-n:] if n < len(all_lines) else all_lines
|
|
187
|
+
return json.dumps({
|
|
188
|
+
"total_lines": len(all_lines),
|
|
189
|
+
"lines_returned": len(selected),
|
|
190
|
+
"content": "".join(selected),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# -- grep (search file contents) ------------------------------------------
|
|
195
|
+
|
|
196
|
+
class GrepParams(BaseModel):
|
|
197
|
+
path: str = Field(description="Absolute path to the file to search")
|
|
198
|
+
pattern: str = Field(description="Search pattern (plain text or regex)")
|
|
199
|
+
is_regex: Optional[bool] = Field(default=False, description="Treat pattern as a regex (default: plain text)")
|
|
200
|
+
ignore_case: Optional[bool] = Field(default=True, description="Case-insensitive search (default: true)")
|
|
201
|
+
max_results: Optional[int] = Field(default=50, description="Maximum number of matching lines to return (default 50)")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@define_tool(
|
|
205
|
+
description=(
|
|
206
|
+
"Search for a pattern in a file on the local system. Returns matching"
|
|
207
|
+
" lines with line numbers. Supports plain text and regex patterns."
|
|
208
|
+
),
|
|
209
|
+
overrides_built_in_tool=True,
|
|
210
|
+
)
|
|
211
|
+
async def grep(params: GrepParams) -> str:
|
|
212
|
+
err = _check_access(params.path)
|
|
213
|
+
if err:
|
|
214
|
+
return err
|
|
215
|
+
|
|
216
|
+
lines = _read_lines(params.path)
|
|
217
|
+
flags = re.IGNORECASE if params.ignore_case else 0
|
|
218
|
+
limit = max(1, params.max_results or 50)
|
|
219
|
+
|
|
220
|
+
matches = []
|
|
221
|
+
for i, line in enumerate(lines, 1):
|
|
222
|
+
try:
|
|
223
|
+
if params.is_regex:
|
|
224
|
+
found = re.search(params.pattern, line, flags)
|
|
225
|
+
else:
|
|
226
|
+
if params.ignore_case:
|
|
227
|
+
found = params.pattern.lower() in line.lower()
|
|
228
|
+
else:
|
|
229
|
+
found = params.pattern in line
|
|
230
|
+
except re.error as e:
|
|
231
|
+
return json.dumps({"error": f"Invalid regex: {e}"})
|
|
232
|
+
|
|
233
|
+
if found:
|
|
234
|
+
matches.append({"line_number": i, "content": line.rstrip("\n\r")})
|
|
235
|
+
if len(matches) >= limit:
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
return json.dumps({
|
|
239
|
+
"total_lines": len(lines),
|
|
240
|
+
"matches_found": len(matches),
|
|
241
|
+
"truncated": len(matches) >= limit,
|
|
242
|
+
"matches": matches,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# -- jq (query JSON files) ------------------------------------------------
|
|
247
|
+
|
|
248
|
+
class JqParams(BaseModel):
|
|
249
|
+
path: str = Field(description="Absolute path to a JSON file")
|
|
250
|
+
query: str = Field(description="Dot-separated path to extract (e.g. '.results', '.data.items', '.[0].name'). Use '.' for the entire document.")
|
|
251
|
+
max_items: Optional[int] = Field(default=20, description="If the result is an array, return at most this many items (default 20)")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@define_tool(description=(
|
|
255
|
+
"Query a JSON file on the local system using a dot-path expression."
|
|
256
|
+
" Examples: '.' (entire doc), '.key', '.items.[0].name', '.data.results'."
|
|
257
|
+
))
|
|
258
|
+
async def jq(params: JqParams) -> str:
|
|
259
|
+
err = _check_access(params.path)
|
|
260
|
+
if err:
|
|
261
|
+
return err
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
with open(os.path.normpath(params.path), "r", encoding="utf-8") as f:
|
|
265
|
+
data = json.load(f)
|
|
266
|
+
except json.JSONDecodeError as e:
|
|
267
|
+
return json.dumps({"error": f"Invalid JSON: {e}"})
|
|
268
|
+
|
|
269
|
+
# Navigate the dot-path
|
|
270
|
+
query = params.query.strip().lstrip(".")
|
|
271
|
+
current = data
|
|
272
|
+
if query:
|
|
273
|
+
for part in query.split("."):
|
|
274
|
+
if not part:
|
|
275
|
+
continue
|
|
276
|
+
# Handle array index: [0], [1], etc.
|
|
277
|
+
idx_match = re.match(r"^\[(\d+)\]$", part)
|
|
278
|
+
if idx_match:
|
|
279
|
+
idx = int(idx_match.group(1))
|
|
280
|
+
if not isinstance(current, list) or idx >= len(current):
|
|
281
|
+
return json.dumps({"error": f"Index {idx} out of range (length {len(current) if isinstance(current, list) else 'N/A'})"})
|
|
282
|
+
current = current[idx]
|
|
283
|
+
elif isinstance(current, dict) and part in current:
|
|
284
|
+
current = current[part]
|
|
285
|
+
elif isinstance(current, list):
|
|
286
|
+
# Try array index without brackets
|
|
287
|
+
try:
|
|
288
|
+
current = current[int(part)]
|
|
289
|
+
except (ValueError, IndexError):
|
|
290
|
+
return json.dumps({"error": f"Key '{part}' not found. Available keys: {list(current[0].keys()) if current and isinstance(current[0], dict) else 'N/A'}"})
|
|
291
|
+
else:
|
|
292
|
+
available = list(current.keys()) if isinstance(current, dict) else type(current).__name__
|
|
293
|
+
return json.dumps({"error": f"Key '{part}' not found. Available: {available}"})
|
|
294
|
+
|
|
295
|
+
# Truncate arrays
|
|
296
|
+
limit = max(1, params.max_items or 20)
|
|
297
|
+
truncated = False
|
|
298
|
+
if isinstance(current, list) and len(current) > limit:
|
|
299
|
+
total_items = len(current)
|
|
300
|
+
current = current[:limit]
|
|
301
|
+
truncated = True
|
|
302
|
+
else:
|
|
303
|
+
total_items = len(current) if isinstance(current, list) else None
|
|
304
|
+
|
|
305
|
+
result = {"result": current}
|
|
306
|
+
if total_items is not None:
|
|
307
|
+
result["total_items"] = total_items
|
|
308
|
+
if truncated:
|
|
309
|
+
result["truncated"] = True
|
|
310
|
+
result["items_returned"] = limit
|
|
311
|
+
return json.dumps(result, indent=2, default=str)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
_BUILTIN_TOOLS = [view, head, tail, grep, jq]
|
|
315
|
+
|
|
316
|
+
_REGISTERED_TOOLS_CACHE = discover_tools() + _BUILTIN_TOOLS
|