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,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