agent-framework-hyperlight 1.0.0a260421__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
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+ from ._execute_code_tool import HyperlightExecuteCodeTool
8
+ from ._provider import HyperlightCodeActProvider
9
+ from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountInput
10
+
11
+ try:
12
+ __version__ = importlib.metadata.version(__name__)
13
+ except importlib.metadata.PackageNotFoundError:
14
+ __version__ = "0.0.0"
15
+
16
+ __all__ = [
17
+ "AllowedDomain",
18
+ "AllowedDomainInput",
19
+ "FileMount",
20
+ "FileMountInput",
21
+ "HyperlightCodeActProvider",
22
+ "HyperlightExecuteCodeTool",
23
+ "__version__",
24
+ ]
@@ -0,0 +1,865 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import asyncio
7
+ import copy
8
+ import mimetypes
9
+ import shutil
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable, Sequence
13
+ from dataclasses import dataclass
14
+ from pathlib import Path, PurePosixPath
15
+ from tempfile import TemporaryDirectory
16
+ from typing import Annotated, Any, Protocol, TypeGuard, cast
17
+ from urllib.parse import urlparse
18
+
19
+ from agent_framework import Content, FunctionTool
20
+ from agent_framework._tools import ApprovalMode, normalize_tools
21
+ from pydantic import BaseModel, Field
22
+
23
+ from ._instructions import build_codeact_instructions, build_execute_code_description
24
+ from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountHostPath, FileMountInput
25
+
26
+ DEFAULT_HYPERLIGHT_BACKEND = "wasm"
27
+ DEFAULT_HYPERLIGHT_MODULE = "python_guest.path"
28
+ EXECUTE_CODE_INPUT_DESCRIPTION = "Python code to execute in an isolated Hyperlight sandbox."
29
+ OUTPUT_FILE_RETRY_ATTEMPTS = 10
30
+ OUTPUT_FILE_RETRY_DELAY_SECONDS = 0.1
31
+
32
+
33
+ class _ExecuteCodeInput(BaseModel):
34
+ code: Annotated[str, Field(description=EXECUTE_CODE_INPUT_DESCRIPTION)]
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class _StoredFileMount:
39
+ host_path: Path
40
+ mount_path: str
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class _NormalizedFileMount:
45
+ host_path: Path
46
+ mount_path: str
47
+ path_signature: tuple[tuple[str, int, int], ...]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class _RunConfig:
52
+ backend: str
53
+ module: str | None
54
+ module_path: str | None
55
+ approval_mode: ApprovalMode
56
+ tools: tuple[FunctionTool, ...]
57
+ workspace_root: Path | None
58
+ workspace_signature: tuple[tuple[str, int, int], ...]
59
+ file_mounts: tuple[_NormalizedFileMount, ...]
60
+ allowed_domains: tuple[AllowedDomain, ...]
61
+
62
+ @property
63
+ def mounted_paths(self) -> tuple[str, ...]:
64
+ return tuple(_display_mount_path(mount.mount_path) for mount in self.file_mounts)
65
+
66
+ @property
67
+ def filesystem_enabled(self) -> bool:
68
+ return self.workspace_root is not None or bool(self.file_mounts)
69
+
70
+ def cache_key(self) -> tuple[Any, ...]:
71
+ return (
72
+ self.backend,
73
+ self.module,
74
+ self.module_path,
75
+ self.approval_mode,
76
+ tuple((tool_obj.name, id(tool_obj)) for tool_obj in self.tools),
77
+ str(self.workspace_root) if self.workspace_root is not None else None,
78
+ self.workspace_signature,
79
+ tuple((mount.mount_path, str(mount.host_path), mount.path_signature) for mount in self.file_mounts),
80
+ tuple((allowed_domain.target, allowed_domain.methods) for allowed_domain in self.allowed_domains),
81
+ )
82
+
83
+
84
+ class SandboxRuntime(Protocol):
85
+ def execute(self, *, config: _RunConfig, code: str) -> list[Content]: ...
86
+
87
+
88
+ @dataclass
89
+ class _SandboxEntry:
90
+ sandbox: Any
91
+ snapshot: Any
92
+ input_dir: TemporaryDirectory[str] | None
93
+ output_dir: TemporaryDirectory[str] | None
94
+ lock: threading.RLock
95
+
96
+
97
+ def _load_sandbox_class() -> type[Any]:
98
+ try:
99
+ from hyperlight_sandbox import Sandbox
100
+ except ModuleNotFoundError as exc:
101
+ raise ModuleNotFoundError(
102
+ "Hyperlight support requires `hyperlight-sandbox`, `hyperlight-sandbox-python-guest`, "
103
+ "and a compatible backend package such as `hyperlight-sandbox-backend-wasm`."
104
+ ) from exc
105
+
106
+ return Sandbox
107
+
108
+
109
+ def _passthrough_result_parser(result: Any) -> str:
110
+ return repr(result)
111
+
112
+
113
+ def _collect_tools(*tool_groups: Any) -> list[FunctionTool]:
114
+ tools_by_name: dict[str, FunctionTool] = {}
115
+
116
+ for tool_group in tool_groups:
117
+ normalized_group = normalize_tools(tool_group)
118
+ for tool_obj in normalized_group:
119
+ if not isinstance(tool_obj, FunctionTool):
120
+ continue
121
+ if tool_obj.name == "execute_code":
122
+ continue
123
+ tools_by_name.pop(tool_obj.name, None)
124
+ tools_by_name[tool_obj.name] = tool_obj
125
+
126
+ return list(tools_by_name.values())
127
+
128
+
129
+ def _resolve_execute_code_approval_mode(
130
+ *,
131
+ base_approval_mode: ApprovalMode,
132
+ tools: Sequence[FunctionTool],
133
+ ) -> ApprovalMode:
134
+ if base_approval_mode == "always_require":
135
+ return "always_require"
136
+
137
+ if any(tool_obj.approval_mode == "always_require" for tool_obj in tools):
138
+ return "always_require"
139
+
140
+ return "never_require"
141
+
142
+
143
+ def _resolve_existing_path(value: str | Path) -> Path:
144
+ return Path(value).expanduser().resolve(strict=True)
145
+
146
+
147
+ def _resolve_workspace_root(value: str | Path | None) -> Path | None:
148
+ if value is None:
149
+ return None
150
+
151
+ resolved_path = _resolve_existing_path(value)
152
+ if not resolved_path.is_dir():
153
+ raise ValueError("workspace_root must point to an existing directory.")
154
+ return resolved_path
155
+
156
+
157
+ def _is_file_mount_pair(value: Any) -> TypeGuard[FileMount | tuple[FileMountHostPath, str]]:
158
+ if not isinstance(value, tuple):
159
+ return False
160
+
161
+ value_tuple = cast(tuple[object, ...], value)
162
+ if len(value_tuple) != 2:
163
+ return False
164
+
165
+ host_path, mount_path = value_tuple
166
+ return isinstance(host_path, (str, Path)) and isinstance(mount_path, str)
167
+
168
+
169
+ def _normalize_file_mount_input(file_mount: FileMountInput) -> _StoredFileMount:
170
+ host_path: FileMountHostPath
171
+ mount_path: str
172
+ if isinstance(file_mount, str):
173
+ host_path = file_mount
174
+ mount_path = file_mount
175
+ else:
176
+ host_path = file_mount[0]
177
+ mount_path = file_mount[1]
178
+
179
+ return _StoredFileMount(
180
+ host_path=_resolve_existing_path(host_path),
181
+ mount_path=_normalize_mount_path(mount_path),
182
+ )
183
+
184
+
185
+ def _normalize_domain(target: str) -> str:
186
+ candidate = target.strip()
187
+ if not candidate:
188
+ raise ValueError("Allowed domain entries must not be empty.")
189
+
190
+ parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
191
+ normalized = (parsed.netloc or parsed.path).strip().rstrip("/")
192
+ if not normalized:
193
+ raise ValueError(f"Could not normalize allowed domain entry: {target!r}.")
194
+ return normalized.lower()
195
+
196
+
197
+ def _normalize_http_method(method: str) -> str:
198
+ normalized = method.strip().upper()
199
+ if not normalized:
200
+ raise ValueError("HTTP method entries must not be empty.")
201
+ return normalized
202
+
203
+
204
+ def _normalize_http_methods(methods: str | Sequence[str] | None) -> tuple[str, ...] | None:
205
+ if methods is None:
206
+ return None
207
+
208
+ normalized_methods = (
209
+ {_normalize_http_method(methods)}
210
+ if isinstance(methods, str)
211
+ else {_normalize_http_method(method) for method in methods}
212
+ )
213
+ if not normalized_methods:
214
+ raise ValueError("Allowed domain methods must not be empty when provided.")
215
+ return tuple(sorted(normalized_methods))
216
+
217
+
218
+ def _is_allowed_domain_pair(value: Any) -> TypeGuard[tuple[str, str | Sequence[str]]]:
219
+ if not isinstance(value, tuple) or isinstance(value, AllowedDomain):
220
+ return False
221
+
222
+ value_tuple = cast(tuple[object, ...], value)
223
+ if len(value_tuple) != 2:
224
+ return False
225
+
226
+ target, methods = value_tuple
227
+ if not isinstance(target, str):
228
+ return False
229
+ if isinstance(methods, str):
230
+ return True
231
+ return isinstance(methods, Sequence)
232
+
233
+
234
+ def _normalize_allowed_domain_input(allowed_domain: AllowedDomainInput) -> AllowedDomain:
235
+ if isinstance(allowed_domain, str):
236
+ return AllowedDomain(target=_normalize_domain(allowed_domain), methods=None)
237
+
238
+ if isinstance(allowed_domain, AllowedDomain):
239
+ return AllowedDomain(
240
+ target=_normalize_domain(allowed_domain.target),
241
+ methods=_normalize_http_methods(allowed_domain.methods),
242
+ )
243
+
244
+ target, methods = allowed_domain
245
+ return AllowedDomain(
246
+ target=_normalize_domain(target),
247
+ methods=_normalize_http_methods(methods),
248
+ )
249
+
250
+
251
+ def _allowed_domain_registration_targets(*, target: str, expand_missing_scheme: bool) -> tuple[str, ...]:
252
+ if not expand_missing_scheme or "://" in target:
253
+ return (target,)
254
+ return (f"http://{target}", f"https://{target}")
255
+
256
+
257
+ def _should_retry_allowed_domain_registration(
258
+ *,
259
+ error: RuntimeError,
260
+ allowed_domains: Sequence[AllowedDomain],
261
+ ) -> bool:
262
+ message = str(error).lower()
263
+ return "invalid url for network permission" in message and any(
264
+ "://" not in domain.target for domain in allowed_domains
265
+ )
266
+
267
+
268
+ def _normalize_mount_path(mount_path: str) -> str:
269
+ raw_path = mount_path.strip().replace("\\", "/")
270
+ if not raw_path:
271
+ raise ValueError("mount_path must not be empty.")
272
+
273
+ pure_path = PurePosixPath(raw_path)
274
+ parts = [part for part in pure_path.parts if part not in {"", "/", "."}]
275
+ if parts and parts[0] == "input":
276
+ parts = parts[1:]
277
+ if any(part == ".." for part in parts):
278
+ raise ValueError("mount_path must stay within /input.")
279
+ if not parts:
280
+ raise ValueError("mount_path must point to a concrete path under /input.")
281
+ return "/".join(parts)
282
+
283
+
284
+ def _display_mount_path(mount_path: str) -> str:
285
+ return f"/input/{mount_path}"
286
+
287
+
288
+ def _path_tree_signature(path: Path) -> tuple[tuple[str, int, int], ...]:
289
+ if path.is_file():
290
+ stat = path.stat()
291
+ return ((path.name, int(stat.st_size), int(stat.st_mtime_ns)),)
292
+
293
+ entries: list[tuple[str, int, int]] = []
294
+ for candidate in sorted(path.rglob("*"), key=lambda value: value.as_posix()):
295
+ try:
296
+ stat = candidate.stat()
297
+ except FileNotFoundError:
298
+ continue
299
+ relative_path = candidate.relative_to(path).as_posix()
300
+ size = int(stat.st_size) if candidate.is_file() else 0
301
+ entries.append((relative_path, size, int(stat.st_mtime_ns)))
302
+ return tuple(entries)
303
+
304
+
305
+ def _copy_path(source: Path, destination: Path) -> None:
306
+ if source.is_dir():
307
+ destination.mkdir(parents=True, exist_ok=True)
308
+ for child in sorted(source.iterdir(), key=lambda value: value.name):
309
+ _copy_path(child, destination / child.name)
310
+ return
311
+
312
+ destination.parent.mkdir(parents=True, exist_ok=True)
313
+ shutil.copy2(source, destination)
314
+
315
+
316
+ def _populate_input_dir(*, config: _RunConfig, input_root: Path) -> None:
317
+ if config.workspace_root is not None:
318
+ for child in sorted(config.workspace_root.iterdir(), key=lambda value: value.name):
319
+ _copy_path(child, input_root / child.name)
320
+
321
+ for mount in config.file_mounts:
322
+ _copy_path(mount.host_path, input_root / mount.mount_path)
323
+
324
+
325
+ def _create_file_content(file_path: Path, *, relative_path: str) -> Content:
326
+ media_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
327
+ return Content.from_data(
328
+ data=file_path.read_bytes(),
329
+ media_type=media_type,
330
+ additional_properties={"path": f"/output/{relative_path}"},
331
+ )
332
+
333
+
334
+ def _normalize_output_relative_path(*, output_file: object, root: Path) -> str | None:
335
+ candidate_path = Path(str(output_file))
336
+ if candidate_path.is_absolute():
337
+ try:
338
+ return candidate_path.relative_to(root).as_posix()
339
+ except ValueError:
340
+ return None
341
+
342
+ raw_path = str(output_file).replace("\\", "/")
343
+ pure_path = PurePosixPath(raw_path)
344
+ parts = [part for part in pure_path.parts if part not in {"", "/", "."}]
345
+ if parts and parts[0] == "output":
346
+ parts = parts[1:]
347
+ if not parts or any(part == ".." for part in parts):
348
+ return None
349
+ return "/".join(parts)
350
+
351
+
352
+ def _collect_output_relative_paths(*, sandbox: Any, root: Path) -> set[str]:
353
+ relative_paths: set[str] = set()
354
+
355
+ if hasattr(sandbox, "get_output_files"):
356
+ try:
357
+ output_files = cast(Sequence[object], sandbox.get_output_files())
358
+ except Exception:
359
+ output_files = ()
360
+
361
+ for output_file in output_files:
362
+ if (relative_path := _normalize_output_relative_path(output_file=output_file, root=root)) is not None:
363
+ relative_paths.add(relative_path)
364
+
365
+ for host_path in root.rglob("*"):
366
+ if host_path.is_file():
367
+ relative_paths.add(host_path.relative_to(root).as_posix())
368
+
369
+ return relative_paths
370
+
371
+
372
+ def _parse_output_files(
373
+ *,
374
+ sandbox: Any,
375
+ output_dir: TemporaryDirectory[str] | None,
376
+ expect_output_files: bool,
377
+ ) -> list[Content]:
378
+ if output_dir is None:
379
+ return []
380
+
381
+ root = Path(output_dir.name)
382
+
383
+ for attempt in range(OUTPUT_FILE_RETRY_ATTEMPTS):
384
+ relative_paths = _collect_output_relative_paths(sandbox=sandbox, root=root)
385
+ missing_files = expect_output_files and not relative_paths
386
+ contents: list[Content] = []
387
+
388
+ for relative_path in sorted(relative_paths):
389
+ host_path = root.joinpath(*PurePosixPath(relative_path).parts)
390
+ if not host_path.is_file():
391
+ missing_files = True
392
+ continue
393
+ try:
394
+ contents.append(_create_file_content(host_path, relative_path=relative_path))
395
+ except PermissionError:
396
+ missing_files = True
397
+
398
+ if not missing_files or attempt == OUTPUT_FILE_RETRY_ATTEMPTS - 1:
399
+ return contents
400
+
401
+ time.sleep(OUTPUT_FILE_RETRY_DELAY_SECONDS)
402
+
403
+ return []
404
+
405
+
406
+ def _build_execution_contents(
407
+ *,
408
+ result: Any,
409
+ sandbox: Any,
410
+ output_dir: TemporaryDirectory[str] | None,
411
+ code: str,
412
+ ) -> list[Content]:
413
+ success = bool(getattr(result, "success", False))
414
+ stdout = str(getattr(result, "stdout", "") or "").replace("\r\n", "\n") or None
415
+ stderr = str(getattr(result, "stderr", "") or "").replace("\r\n", "\n") or None
416
+ outputs: list[Content] = []
417
+
418
+ if stdout is not None:
419
+ outputs.append(Content.from_text(stdout, raw_representation=result))
420
+
421
+ outputs.extend(
422
+ _parse_output_files(
423
+ sandbox=sandbox,
424
+ output_dir=output_dir,
425
+ expect_output_files="/output" in code,
426
+ )
427
+ )
428
+
429
+ if success:
430
+ if stderr is not None:
431
+ outputs.append(Content.from_text(stderr, raw_representation=result))
432
+ if not outputs:
433
+ outputs.append(Content.from_text("Code executed successfully without output."))
434
+ return outputs
435
+
436
+ error_details = stderr or "Unknown sandbox error"
437
+ outputs.append(
438
+ Content.from_error(
439
+ message="Execution error",
440
+ error_details=error_details,
441
+ raw_representation=result,
442
+ )
443
+ )
444
+ return outputs
445
+
446
+
447
+ def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]:
448
+ sandbox_tool = copy.copy(tool_obj)
449
+ # Auto-assign a passthrough parser so the raw return value round-trips through
450
+ # `ast.literal_eval` in the sandbox callback below. User-supplied parsers are
451
+ # left in place so callers can customize how results are exposed to the guest.
452
+ if sandbox_tool.result_parser is None:
453
+ sandbox_tool.result_parser = _passthrough_result_parser
454
+
455
+ def _callback(**kwargs: Any) -> Any:
456
+ async def _invoke() -> list[Content]:
457
+ return await sandbox_tool.invoke(arguments=kwargs)
458
+
459
+ # FunctionTool.invoke() is always async. The real Hyperlight backend invokes
460
+ # registered callbacks synchronously via FFI, so this must be a sync function.
461
+ # We run the async call on a dedicated thread to avoid conflicts with any
462
+ # event loop that may be running on the current thread.
463
+ result_box: list[Any] = [None]
464
+ error_box: list[BaseException] = []
465
+
466
+ def _run() -> None:
467
+ try:
468
+ result_box[0] = asyncio.run(_invoke())
469
+ except BaseException as exc:
470
+ error_box.append(exc)
471
+
472
+ worker = threading.Thread(target=_run)
473
+ worker.start()
474
+ worker.join()
475
+ if error_box:
476
+ raise error_box[0]
477
+ contents: list[Content] = result_box[0]
478
+
479
+ values: list[Any] = []
480
+ for content in contents:
481
+ if content.type == "text" and content.text is not None:
482
+ try:
483
+ values.append(ast.literal_eval(content.text))
484
+ except (SyntaxError, ValueError):
485
+ values.append(content.text)
486
+ continue
487
+
488
+ values.append(content.to_dict())
489
+
490
+ if len(values) == 1:
491
+ return values[0]
492
+ return values
493
+
494
+ return _callback
495
+
496
+
497
+ def _clear_directory(output_dir: TemporaryDirectory[str] | None) -> None:
498
+ """Remove all contents of the output directory without deleting the directory itself."""
499
+ if output_dir is None:
500
+ return
501
+ root = Path(output_dir.name)
502
+ for child in root.iterdir():
503
+ try:
504
+ if child.is_symlink() or child.is_file():
505
+ child.unlink()
506
+ elif child.is_dir():
507
+ shutil.rmtree(child, ignore_errors=True)
508
+ except (FileNotFoundError, PermissionError):
509
+ pass
510
+
511
+
512
+ class _SandboxRegistry:
513
+ def __init__(self) -> None:
514
+ self._entries: dict[tuple[Any, ...], _SandboxEntry] = {}
515
+ self._entries_lock = threading.RLock()
516
+
517
+ def execute(self, *, config: _RunConfig, code: str) -> list[Content]:
518
+ """Execute code in a cached sandbox matching the given config.
519
+
520
+ Entries are keyed by ``config.cache_key()``. Concurrent calls with the same
521
+ key are serialized by the entry lock so they never race, but they share the
522
+ same sandbox instance. For true parallel execution, use distinct provider
523
+ instances or configs that produce different cache keys.
524
+ """
525
+ cache_key = config.cache_key()
526
+ with self._entries_lock:
527
+ entry = self._entries.get(cache_key)
528
+ if entry is None:
529
+ entry = self._create_entry(config)
530
+ self._entries[cache_key] = entry
531
+
532
+ with entry.lock:
533
+ entry.sandbox.restore(entry.snapshot)
534
+ _clear_directory(entry.output_dir)
535
+ result = entry.sandbox.run(code=code)
536
+ return _build_execution_contents(
537
+ result=result,
538
+ sandbox=entry.sandbox,
539
+ output_dir=entry.output_dir,
540
+ code=code,
541
+ )
542
+
543
+ def _create_entry(self, config: _RunConfig) -> _SandboxEntry:
544
+ input_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None
545
+ output_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None
546
+
547
+ if input_dir_handle is not None:
548
+ _populate_input_dir(config=config, input_root=Path(input_dir_handle.name))
549
+
550
+ sandbox_cls = _load_sandbox_class()
551
+
552
+ def _create_sandbox() -> Any:
553
+ try:
554
+ return sandbox_cls(
555
+ backend=config.backend,
556
+ module=config.module,
557
+ module_path=config.module_path,
558
+ input_dir=input_dir_handle.name if input_dir_handle is not None else None,
559
+ output_dir=output_dir_handle.name if output_dir_handle is not None else None,
560
+ )
561
+ except ImportError as exc:
562
+ raise RuntimeError(
563
+ "The selected Hyperlight backend is not installed or not supported on this platform. "
564
+ "Install a compatible backend package, such as `hyperlight-sandbox-backend-wasm`."
565
+ ) from exc
566
+
567
+ def _configure_sandbox(*, sandbox: Any, expand_missing_scheme: bool) -> None:
568
+ for tool_obj in config.tools:
569
+ sandbox.register_tool(tool_obj.name, _make_sandbox_callback(tool_obj))
570
+
571
+ for allowed_domain in config.allowed_domains:
572
+ for target in _allowed_domain_registration_targets(
573
+ target=allowed_domain.target,
574
+ expand_missing_scheme=expand_missing_scheme,
575
+ ):
576
+ sandbox.allow_domain(
577
+ target,
578
+ methods=list(allowed_domain.methods) if allowed_domain.methods is not None else None,
579
+ )
580
+
581
+ sandbox = _create_sandbox()
582
+ _configure_sandbox(sandbox=sandbox, expand_missing_scheme=False)
583
+
584
+ try:
585
+ sandbox.run("None")
586
+ except RuntimeError as exc:
587
+ if not _should_retry_allowed_domain_registration(error=exc, allowed_domains=config.allowed_domains):
588
+ raise
589
+
590
+ sandbox = _create_sandbox()
591
+ _configure_sandbox(sandbox=sandbox, expand_missing_scheme=True)
592
+ sandbox.run("None")
593
+
594
+ snapshot = sandbox.snapshot()
595
+ return _SandboxEntry(
596
+ sandbox=sandbox,
597
+ snapshot=snapshot,
598
+ input_dir=input_dir_handle,
599
+ output_dir=output_dir_handle,
600
+ lock=threading.RLock(),
601
+ )
602
+
603
+
604
+ class HyperlightExecuteCodeTool(FunctionTool):
605
+ """Execute Python code inside a Hyperlight sandbox."""
606
+
607
+ def __init__(
608
+ self,
609
+ *,
610
+ tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
611
+ approval_mode: ApprovalMode | None = None,
612
+ workspace_root: str | Path | None = None,
613
+ file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
614
+ allowed_domains: AllowedDomainInput | Sequence[AllowedDomainInput] | None = None,
615
+ backend: str = DEFAULT_HYPERLIGHT_BACKEND,
616
+ module: str | None = DEFAULT_HYPERLIGHT_MODULE,
617
+ module_path: str | None = None,
618
+ _registry: SandboxRuntime | None = None,
619
+ ) -> None:
620
+ super().__init__(
621
+ name="execute_code",
622
+ description=EXECUTE_CODE_INPUT_DESCRIPTION,
623
+ approval_mode="never_require",
624
+ func=self._run_code,
625
+ input_model=_ExecuteCodeInput,
626
+ )
627
+ self._state_lock = threading.RLock()
628
+ self._registry = _registry or _SandboxRegistry()
629
+ self._default_approval_mode: ApprovalMode = approval_mode or "never_require"
630
+ self._workspace_root = _resolve_workspace_root(workspace_root)
631
+ self._backend: str = backend
632
+ self._module: str | None = module
633
+ self._module_path: str | None = module_path
634
+ self._managed_tools: list[FunctionTool] = []
635
+ self._file_mounts: dict[str, _StoredFileMount] = {}
636
+ self._allowed_domains: dict[str, AllowedDomain] = {}
637
+
638
+ if tools is not None:
639
+ self.add_tools(tools)
640
+ if file_mounts is not None:
641
+ self.add_file_mounts(file_mounts)
642
+ if allowed_domains is not None:
643
+ self.add_allowed_domains(allowed_domains)
644
+
645
+ self._refresh_approval_mode()
646
+
647
+ @property
648
+ def description(self) -> str:
649
+ state_lock = getattr(self, "_state_lock", None)
650
+ if state_lock is None:
651
+ return str(self.__dict__.get("description", EXECUTE_CODE_INPUT_DESCRIPTION))
652
+
653
+ with state_lock:
654
+ allowed_domains = sorted(self._allowed_domains.values(), key=lambda value: value.target)
655
+ return build_execute_code_description(
656
+ tools=self._managed_tools,
657
+ filesystem_enabled=self._workspace_root is not None or bool(self._file_mounts),
658
+ workspace_enabled=self._workspace_root is not None,
659
+ mounted_paths=[_display_mount_path(mount.mount_path) for mount in self._file_mounts.values()],
660
+ allowed_domains=allowed_domains,
661
+ )
662
+
663
+ @description.setter
664
+ def description(self, value: str) -> None:
665
+ self.__dict__["description"] = value
666
+
667
+ def add_tools(
668
+ self,
669
+ tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
670
+ ) -> None:
671
+ """Add sandbox-managed tools to this execute_code surface."""
672
+ with self._state_lock:
673
+ combined_tools = _collect_tools(self._managed_tools, tools)
674
+ self._managed_tools = combined_tools
675
+ self._refresh_approval_mode()
676
+
677
+ def get_tools(self) -> list[FunctionTool]:
678
+ """Return the currently managed sandbox tools."""
679
+ with self._state_lock:
680
+ return list(self._managed_tools)
681
+
682
+ def remove_tool(self, name: str) -> None:
683
+ """Remove one managed sandbox tool by name."""
684
+ with self._state_lock:
685
+ remaining_tools = [tool_obj for tool_obj in self._managed_tools if tool_obj.name != name]
686
+ if len(remaining_tools) == len(self._managed_tools):
687
+ raise KeyError(f"No managed tool named {name!r} is registered.")
688
+ self._managed_tools = remaining_tools
689
+ self._refresh_approval_mode()
690
+
691
+ def clear_tools(self) -> None:
692
+ """Remove all managed sandbox tools."""
693
+ with self._state_lock:
694
+ self._managed_tools = []
695
+ self._refresh_approval_mode()
696
+
697
+ def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None:
698
+ """Add one or more file mounts under `/input`.
699
+
700
+ A single string uses the same relative path on the host and in the sandbox.
701
+ Use a two-string tuple or `FileMount` when those paths differ.
702
+ """
703
+ if isinstance(file_mounts, str) or _is_file_mount_pair(file_mounts):
704
+ normalized_mounts = [_normalize_file_mount_input(file_mounts)]
705
+ else:
706
+ normalized_mounts = [
707
+ _normalize_file_mount_input(mount) for mount in cast(Sequence[FileMountInput], file_mounts)
708
+ ]
709
+
710
+ with self._state_lock:
711
+ for mount in normalized_mounts:
712
+ self._file_mounts[mount.mount_path] = mount
713
+
714
+ def get_file_mounts(self) -> list[FileMount]:
715
+ """Return the configured file mounts."""
716
+ with self._state_lock:
717
+ return [
718
+ FileMount(host_path=mount.host_path, mount_path=_display_mount_path(mount.mount_path))
719
+ for mount in self._file_mounts.values()
720
+ ]
721
+
722
+ def remove_file_mount(self, mount_path: str) -> None:
723
+ """Remove one file mount by its sandbox path."""
724
+ normalized_mount_path = _normalize_mount_path(mount_path)
725
+ with self._state_lock:
726
+ if normalized_mount_path not in self._file_mounts:
727
+ raise KeyError(f"No file mount exists for {mount_path!r}.")
728
+ del self._file_mounts[normalized_mount_path]
729
+
730
+ def clear_file_mounts(self) -> None:
731
+ """Remove all configured file mounts."""
732
+ with self._state_lock:
733
+ self._file_mounts.clear()
734
+
735
+ def add_allowed_domains(self, domains: AllowedDomainInput | Sequence[AllowedDomainInput]) -> None:
736
+ """Add one or more outbound allow-list entries."""
737
+ if isinstance(domains, (str, AllowedDomain)) or _is_allowed_domain_pair(domains):
738
+ normalized_domains = [_normalize_allowed_domain_input(domains)]
739
+ else:
740
+ normalized_domains = [
741
+ _normalize_allowed_domain_input(domain) for domain in cast(Sequence[AllowedDomainInput], domains)
742
+ ]
743
+
744
+ with self._state_lock:
745
+ for normalized_domain in normalized_domains:
746
+ self._allowed_domains[normalized_domain.target] = normalized_domain
747
+
748
+ def get_allowed_domains(self) -> list[AllowedDomain]:
749
+ """Return the configured outbound allow-list entries."""
750
+ with self._state_lock:
751
+ return sorted(self._allowed_domains.values(), key=lambda value: value.target)
752
+
753
+ def remove_allowed_domain(self, domain: str) -> None:
754
+ """Remove one outbound allow-list entry."""
755
+ normalized_domain = _normalize_domain(domain)
756
+ with self._state_lock:
757
+ if normalized_domain not in self._allowed_domains:
758
+ raise KeyError(f"No allowed domain exists for {domain!r}.")
759
+ del self._allowed_domains[normalized_domain]
760
+
761
+ def clear_allowed_domains(self) -> None:
762
+ """Remove all outbound allow-list entries."""
763
+ with self._state_lock:
764
+ self._allowed_domains.clear()
765
+
766
+ def build_instructions(self, *, tools_visible_to_model: bool) -> str:
767
+ """Build the current CodeAct instructions for this execute_code surface."""
768
+ config = self._build_run_config()
769
+ return build_codeact_instructions(
770
+ tools=config.tools,
771
+ tools_visible_to_model=tools_visible_to_model,
772
+ filesystem_enabled=config.filesystem_enabled,
773
+ )
774
+
775
+ def create_run_tool(self) -> HyperlightExecuteCodeTool:
776
+ """Create a run-scoped snapshot of this execute_code surface."""
777
+ file_mounts = self.get_file_mounts()
778
+ allowed_domains = self.get_allowed_domains()
779
+
780
+ return HyperlightExecuteCodeTool(
781
+ tools=self.get_tools(),
782
+ approval_mode=self._default_approval_mode,
783
+ workspace_root=self._workspace_root,
784
+ file_mounts=file_mounts or None,
785
+ allowed_domains=allowed_domains or None,
786
+ backend=self._backend,
787
+ module=self._module,
788
+ module_path=self._module_path,
789
+ _registry=self._registry,
790
+ )
791
+
792
+ def build_serializable_state(self) -> dict[str, Any]:
793
+ """Return a JSON-serializable snapshot of the effective run state."""
794
+ config = self._build_run_config()
795
+ return {
796
+ "backend": config.backend,
797
+ "module": config.module,
798
+ "module_path": config.module_path,
799
+ "approval_mode": config.approval_mode,
800
+ "tool_names": [tool_obj.name for tool_obj in config.tools],
801
+ "filesystem_enabled": config.filesystem_enabled,
802
+ "workspace_root": str(config.workspace_root) if config.workspace_root is not None else None,
803
+ "file_mounts": [
804
+ {
805
+ "host_path": str(mount.host_path),
806
+ "mount_path": _display_mount_path(mount.mount_path),
807
+ }
808
+ for mount in config.file_mounts
809
+ ],
810
+ "network_enabled": bool(config.allowed_domains),
811
+ "allowed_domains": [
812
+ {
813
+ "target": allowed_domain.target,
814
+ "methods": list(allowed_domain.methods) if allowed_domain.methods is not None else None,
815
+ }
816
+ for allowed_domain in config.allowed_domains
817
+ ],
818
+ }
819
+
820
+ def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
821
+ self.__dict__["description"] = self.description
822
+ return super().to_dict(exclude=exclude, exclude_none=exclude_none)
823
+
824
+ def _refresh_approval_mode(self) -> None:
825
+ self.approval_mode = _resolve_execute_code_approval_mode(
826
+ base_approval_mode=self._default_approval_mode,
827
+ tools=self._managed_tools,
828
+ )
829
+
830
+ def _build_run_config(self) -> _RunConfig:
831
+ with self._state_lock:
832
+ managed_tools = tuple(self._managed_tools)
833
+ workspace_root = self._workspace_root
834
+ stored_mounts = tuple(self._file_mounts.values())
835
+ allowed_domains = tuple(sorted(self._allowed_domains.values(), key=lambda value: value.target))
836
+ approval_mode = _resolve_execute_code_approval_mode(
837
+ base_approval_mode=self._default_approval_mode,
838
+ tools=managed_tools,
839
+ )
840
+
841
+ workspace_signature = _path_tree_signature(workspace_root) if workspace_root is not None else ()
842
+ normalized_mounts = tuple(
843
+ _NormalizedFileMount(
844
+ host_path=mount.host_path,
845
+ mount_path=mount.mount_path,
846
+ path_signature=_path_tree_signature(mount.host_path),
847
+ )
848
+ for mount in stored_mounts
849
+ )
850
+
851
+ return _RunConfig(
852
+ backend=self._backend,
853
+ module=self._module,
854
+ module_path=self._module_path,
855
+ approval_mode=approval_mode,
856
+ tools=managed_tools,
857
+ workspace_root=workspace_root,
858
+ workspace_signature=workspace_signature,
859
+ file_mounts=normalized_mounts,
860
+ allowed_domains=allowed_domains,
861
+ )
862
+
863
+ async def _run_code(self, *, code: str) -> list[Content]:
864
+ config = self._build_run_config()
865
+ return await asyncio.to_thread(self._registry.execute, config=config, code=code)
@@ -0,0 +1,139 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from agent_framework import FunctionTool
8
+
9
+ from ._types import AllowedDomain
10
+
11
+
12
+ def _format_tool_summaries(tools: Sequence[FunctionTool]) -> str:
13
+ if not tools:
14
+ return "- No tools are currently registered inside the sandbox."
15
+
16
+ lines: list[str] = []
17
+ for tool_obj in tools:
18
+ parameters = tool_obj.parameters().get("properties", {})
19
+ parameter_names = [name for name in parameters if isinstance(name, str)]
20
+ parameter_summary = ", ".join(parameter_names) if parameter_names else "none"
21
+ description = str(tool_obj.description or "").strip() or "No description provided."
22
+ lines.append(f"- `{tool_obj.name}`: {description} Parameters: {parameter_summary}.")
23
+ return "\n".join(lines)
24
+
25
+
26
+ def _format_filesystem_capabilities(
27
+ *,
28
+ filesystem_enabled: bool,
29
+ workspace_enabled: bool,
30
+ mounted_paths: Sequence[str],
31
+ ) -> str:
32
+ if not filesystem_enabled:
33
+ return "Filesystem access is unavailable because no workspace root or file mounts are configured."
34
+
35
+ lines = ["Filesystem access is enabled."]
36
+ lines.append("Read files from `/input`.")
37
+ lines.append("Write generated artifacts to `/output`; returned files will be attached to the tool result.")
38
+
39
+ if workspace_enabled:
40
+ lines.append("The configured workspace root is available under `/input/`.")
41
+
42
+ if mounted_paths:
43
+ lines.append("Additional mounted paths:")
44
+ lines.extend(f"- `{mounted_path}`" for mounted_path in mounted_paths)
45
+ elif not workspace_enabled:
46
+ lines.append("No workspace root or explicit file mounts are currently configured.")
47
+
48
+ return "\n".join(lines)
49
+
50
+
51
+ def _format_network_capabilities(
52
+ *,
53
+ allowed_domains: Sequence[AllowedDomain],
54
+ ) -> str:
55
+ if not allowed_domains:
56
+ return "Outbound network access is unavailable because no allow-listed targets are configured."
57
+
58
+ lines = ["Outbound network access is allowed only for these configured targets:"]
59
+ for allowed_domain in allowed_domains:
60
+ methods_text = (
61
+ ", ".join(allowed_domain.methods) if allowed_domain.methods else "all methods allowed by the backend"
62
+ )
63
+ lines.append(f"- `{allowed_domain.target}`: {methods_text}.")
64
+ return "\n".join(lines)
65
+
66
+
67
+ def build_codeact_instructions(
68
+ *,
69
+ tools: Sequence[FunctionTool],
70
+ tools_visible_to_model: bool,
71
+ filesystem_enabled: bool = False,
72
+ ) -> str:
73
+ """Build dynamic CodeAct instructions for the effective sandbox state."""
74
+ usage_note = (
75
+ "Some tools may also appear directly, but prefer `execute_code` whenever you need to combine Python "
76
+ "control flow with sandbox tool calls."
77
+ if tools_visible_to_model
78
+ else "Provider-owned sandbox tools are not exposed separately; use `execute_code` when you need them."
79
+ )
80
+
81
+ output_note = (
82
+ "To surface results from `execute_code`, end the code with `print(...)`; the sandbox does not "
83
+ "return the value of the last expression."
84
+ )
85
+ if filesystem_enabled:
86
+ output_note += (
87
+ " For larger artifacts, write them to `/output/<filename>` instead — returned files will be "
88
+ "attached to the tool result."
89
+ )
90
+
91
+ return f"""You have one primary tool: execute_code.
92
+
93
+ Prefer one execute_code call per request when possible.
94
+ Its tool description contains the current `call_tool(...)` guidance, sandbox
95
+ tool registry, and capability limits.
96
+
97
+ {output_note}
98
+
99
+ {usage_note}
100
+ """
101
+
102
+
103
+ def build_execute_code_description(
104
+ *,
105
+ tools: Sequence[FunctionTool],
106
+ filesystem_enabled: bool,
107
+ workspace_enabled: bool,
108
+ mounted_paths: Sequence[str],
109
+ allowed_domains: Sequence[AllowedDomain],
110
+ ) -> str:
111
+ """Build the dynamic execute_code tool description for standalone usage."""
112
+ filesystem_text = _format_filesystem_capabilities(
113
+ filesystem_enabled=filesystem_enabled,
114
+ workspace_enabled=workspace_enabled,
115
+ mounted_paths=mounted_paths,
116
+ )
117
+ network_text = _format_network_capabilities(
118
+ allowed_domains=allowed_domains,
119
+ )
120
+
121
+ return f"""Execute Python in an isolated Hyperlight sandbox.
122
+
123
+ Inside the sandbox, `call_tool(name, **kwargs)` is available as a built-in for
124
+ registered host callbacks. Use the tool name as the first argument and keyword
125
+ arguments only. Do not pass a dict or any other positional arguments after the
126
+ tool name.
127
+
128
+ Registered sandbox tools:
129
+ {_format_tool_summaries(tools)}
130
+
131
+ Filesystem capabilities:
132
+ {filesystem_text}
133
+
134
+ Network capabilities:
135
+ {network_text}
136
+
137
+ Prefer `execute_code` when you need to combine one or more `call_tool(...)`
138
+ calls with Python control flow, loops, or post-processing.
139
+ """
@@ -0,0 +1,111 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext
10
+ from agent_framework._tools import ApprovalMode
11
+
12
+ from ._execute_code_tool import HyperlightExecuteCodeTool, SandboxRuntime
13
+ from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountInput
14
+
15
+
16
+ class HyperlightCodeActProvider(ContextProvider):
17
+ """Inject a Hyperlight-backed CodeAct surface using provider-owned tools."""
18
+
19
+ DEFAULT_SOURCE_ID = "hyperlight_codeact"
20
+
21
+ def __init__(
22
+ self,
23
+ source_id: str = DEFAULT_SOURCE_ID,
24
+ *,
25
+ tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
26
+ approval_mode: ApprovalMode | None = None,
27
+ workspace_root: str | Path | None = None,
28
+ file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
29
+ allowed_domains: AllowedDomainInput | Sequence[AllowedDomainInput] | None = None,
30
+ backend: str = "wasm",
31
+ module: str | None = "python_guest.path",
32
+ module_path: str | None = None,
33
+ _registry: SandboxRuntime | None = None,
34
+ ) -> None:
35
+ super().__init__(source_id)
36
+ self._execute_code_tool = HyperlightExecuteCodeTool(
37
+ tools=tools,
38
+ approval_mode=approval_mode,
39
+ workspace_root=workspace_root,
40
+ file_mounts=file_mounts,
41
+ allowed_domains=allowed_domains,
42
+ backend=backend,
43
+ module=module,
44
+ module_path=module_path,
45
+ _registry=_registry,
46
+ )
47
+
48
+ def add_tools(
49
+ self,
50
+ tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
51
+ ) -> None:
52
+ """Add provider-owned sandbox tools."""
53
+ self._execute_code_tool.add_tools(tools)
54
+
55
+ def get_tools(self) -> list[FunctionTool]:
56
+ """Return the provider-owned sandbox tools."""
57
+ return self._execute_code_tool.get_tools()
58
+
59
+ def remove_tool(self, name: str) -> None:
60
+ """Remove one provider-owned sandbox tool by name."""
61
+ self._execute_code_tool.remove_tool(name)
62
+
63
+ def clear_tools(self) -> None:
64
+ """Remove all provider-owned sandbox tools."""
65
+ self._execute_code_tool.clear_tools()
66
+
67
+ def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None:
68
+ """Add provider-managed file mounts."""
69
+ self._execute_code_tool.add_file_mounts(file_mounts)
70
+
71
+ def get_file_mounts(self) -> list[FileMount]:
72
+ """Return the provider-managed file mounts."""
73
+ return self._execute_code_tool.get_file_mounts()
74
+
75
+ def remove_file_mount(self, mount_path: str) -> None:
76
+ """Remove one provider-managed file mount."""
77
+ self._execute_code_tool.remove_file_mount(mount_path)
78
+
79
+ def clear_file_mounts(self) -> None:
80
+ """Remove all provider-managed file mounts."""
81
+ self._execute_code_tool.clear_file_mounts()
82
+
83
+ def add_allowed_domains(self, domains: AllowedDomainInput | Sequence[AllowedDomainInput]) -> None:
84
+ """Add provider-managed outbound allow-list entries."""
85
+ self._execute_code_tool.add_allowed_domains(domains)
86
+
87
+ def get_allowed_domains(self) -> list[AllowedDomain]:
88
+ """Return the provider-managed outbound allow-list entries."""
89
+ return self._execute_code_tool.get_allowed_domains()
90
+
91
+ def remove_allowed_domain(self, domain: str) -> None:
92
+ """Remove one provider-managed outbound allow-list entry."""
93
+ self._execute_code_tool.remove_allowed_domain(domain)
94
+
95
+ def clear_allowed_domains(self) -> None:
96
+ """Remove all provider-managed outbound allow-list entries."""
97
+ self._execute_code_tool.clear_allowed_domains()
98
+
99
+ async def before_run(
100
+ self,
101
+ *,
102
+ agent: Any,
103
+ session: AgentSession | None,
104
+ context: SessionContext,
105
+ state: dict[str, Any],
106
+ ) -> None:
107
+ """Inject CodeAct instructions and a run-scoped execute_code tool before each run."""
108
+ run_tool = self._execute_code_tool.create_run_tool()
109
+ state[self.source_id] = run_tool.build_serializable_state()
110
+ context.extend_instructions(self.source_id, run_tool.build_instructions(tools_visible_to_model=False))
111
+ context.extend_tools(self.source_id, [run_tool])
@@ -0,0 +1,28 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import NamedTuple, TypeAlias
8
+
9
+
10
+ class FileMount(NamedTuple):
11
+ """Map a host file or directory into the sandbox input tree."""
12
+
13
+ host_path: str | Path
14
+ mount_path: str
15
+
16
+
17
+ FileMountHostPath: TypeAlias = str | Path
18
+ FileMountInput: TypeAlias = str | tuple[FileMountHostPath, str] | FileMount
19
+
20
+
21
+ class AllowedDomain(NamedTuple):
22
+ """Allow outbound requests to one target, optionally restricted to specific HTTP methods."""
23
+
24
+ target: str
25
+ methods: tuple[str, ...] | None = None
26
+
27
+
28
+ AllowedDomainInput: TypeAlias = str | tuple[str, str | Sequence[str]] | AllowedDomain
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-framework-hyperlight
3
+ Version: 1.0.0a260421
4
+ Summary: Hyperlight CodeAct integrations for Microsoft Agent Framework.
5
+ Author-email: Microsoft <af-support@microsoft.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ License-File: LICENSE
18
+ Requires-Dist: agent-framework-core>=1.1.0,<2
19
+ Requires-Dist: hyperlight-sandbox>=0.3.0,<0.4
20
+ Requires-Dist: hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'
21
+ Requires-Dist: hyperlight-sandbox-python-guest>=0.3.0,<0.4
22
+ Project-URL: homepage, https://aka.ms/agent-framework
23
+ Project-URL: issues, https://github.com/microsoft/agent-framework/issues
24
+ Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
25
+ Project-URL: source, https://github.com/microsoft/agent-framework/tree/main/python
26
+
27
+ # agent-framework-hyperlight
28
+
29
+ Alpha Hyperlight-backed CodeAct integrations for Microsoft Agent Framework.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install agent-framework-hyperlight --pre
35
+ ```
36
+
37
+ This package depends on `hyperlight-sandbox`, the packaged Python guest, and the
38
+ Wasm backend package on supported platforms. If the backend is not published for
39
+ your current platform yet, `execute_code` will fail at runtime when it tries to
40
+ create the sandbox.
41
+
42
+ ## Quick start
43
+
44
+ ### Context provider (recommended)
45
+
46
+ Use `HyperlightCodeActProvider` to automatically inject the `execute_code` tool
47
+ and CodeAct instructions into every agent run. Tools registered on the provider
48
+ are available inside the sandbox via `call_tool(...)` but are **not** exposed as
49
+ direct agent tools.
50
+
51
+ ```python
52
+ from agent_framework import Agent, tool
53
+ from agent_framework_hyperlight import HyperlightCodeActProvider
54
+
55
+ @tool
56
+ def compute(operation: str, a: float, b: float) -> float:
57
+ """Perform a math operation."""
58
+ ops = {"add": a + b, "subtract": a - b, "multiply": a * b, "divide": a / b}
59
+ return ops[operation]
60
+
61
+ codeact = HyperlightCodeActProvider(
62
+ tools=[compute],
63
+ approval_mode="never_require",
64
+ )
65
+
66
+ agent = Agent(
67
+ client=client,
68
+ name="CodeActAgent",
69
+ instructions="You are a helpful assistant.",
70
+ context_providers=[codeact],
71
+ )
72
+
73
+ result = await agent.run("Multiply 6 by 7 using execute_code.")
74
+ ```
75
+
76
+ ### Standalone tool
77
+
78
+ Use `HyperlightExecuteCodeTool` directly when you want full control over how the
79
+ tool is added to the agent. This is useful when mixing sandbox tools with
80
+ direct-only tools on the same agent.
81
+
82
+ ```python
83
+ from agent_framework import Agent, tool
84
+ from agent_framework_hyperlight import HyperlightExecuteCodeTool
85
+
86
+ @tool
87
+ def send_email(to: str, subject: str, body: str) -> str:
88
+ """Send an email (direct-only, not available inside the sandbox)."""
89
+ return f"Email sent to {to}"
90
+
91
+ execute_code = HyperlightExecuteCodeTool(
92
+ tools=[compute],
93
+ approval_mode="never_require",
94
+ )
95
+
96
+ agent = Agent(
97
+ client=client,
98
+ name="MixedToolsAgent",
99
+ instructions="You are a helpful assistant.",
100
+ tools=[send_email, execute_code],
101
+ )
102
+ ```
103
+
104
+ ### Manual static wiring
105
+
106
+ For fixed configurations where provider lifecycle overhead is unnecessary, build
107
+ the CodeAct instructions once and pass them to the agent at construction time:
108
+
109
+ ```python
110
+ execute_code = HyperlightExecuteCodeTool(
111
+ tools=[compute],
112
+ approval_mode="never_require",
113
+ )
114
+
115
+ codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False)
116
+
117
+ agent = Agent(
118
+ client=client,
119
+ name="StaticWiringAgent",
120
+ instructions=f"You are a helpful assistant.\n\n{codeact_instructions}",
121
+ tools=[execute_code],
122
+ )
123
+ ```
124
+
125
+ ### File mounts and network access
126
+
127
+ Mount host directories into the sandbox and allow outbound HTTP to specific
128
+ domains:
129
+
130
+ ```python
131
+ from agent_framework_hyperlight import HyperlightCodeActProvider, FileMount
132
+
133
+ codeact = HyperlightCodeActProvider(
134
+ tools=[compute],
135
+ file_mounts=[
136
+ "/host/data", # shorthand — same path in sandbox
137
+ ("/host/models", "/sandbox/models"), # explicit host → sandbox mapping
138
+ FileMount("/host/config", "/sandbox/config"), # named tuple
139
+ ],
140
+ allowed_domains=[
141
+ "api.github.com", # all methods
142
+ ("internal.api.example.com", "GET"), # GET only
143
+ ],
144
+ )
145
+ ```
146
+
147
+ ## Notes
148
+
149
+ - This package is intentionally separate from `agent-framework-core` so CodeAct
150
+ usage and installation remain optional.
151
+ - Alpha-package samples live under `packages/hyperlight/samples/`.
152
+ - `file_mounts` accepts a single string shorthand, an explicit `(host_path,
153
+ mount_path)` pair, or a `FileMount` named tuple. The host-side path in the
154
+ explicit forms may be a `str` or `Path`. Use the explicit two-value form when
155
+ the host path differs from the sandbox path.
156
+ - `allowed_domains` accepts a single string target such as `"github.com"` to
157
+ allow all backend-supported methods, an explicit `(target, method_or_methods)`
158
+ tuple such as `("github.com", "GET")`, or an `AllowedDomain` named tuple.
159
+
@@ -0,0 +1,10 @@
1
+ agent_framework_hyperlight/__init__.py,sha256=7l6LDeWBRjvbszY_Ttbj1v4gig-XHKiTWBERtBd2ZaU,621
2
+ agent_framework_hyperlight/_execute_code_tool.py,sha256=zqxzCs6S-NvKN3LYxpKOTyUyBcic6eLXJGorXF7pG4k,32117
3
+ agent_framework_hyperlight/_instructions.py,sha256=qMkeqHCmuAludNNAxCz7ZW43spc_WBsMvY5iIBG8UFY,4784
4
+ agent_framework_hyperlight/_provider.py,sha256=rYLSB_ZsSFZkZ0VdmVCI-smwTZpKaiauajUEFEePDoo,4475
5
+ agent_framework_hyperlight/_types.py,sha256=aAjssmC3wIJGySMXTrKN4RCXHo643v70g8xzmlCis-A,734
6
+ agent_framework_hyperlight/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
7
+ agent_framework_hyperlight-1.0.0a260421.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
8
+ agent_framework_hyperlight-1.0.0a260421.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
9
+ agent_framework_hyperlight-1.0.0a260421.dist-info/METADATA,sha256=mLTnn0Rt3ibpTclt7mqA_JpqXknQcfqFUanf-8KzlEU,5523
10
+ agent_framework_hyperlight-1.0.0a260421.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE