From ad38aefcdc2611b9128b2ccf5abb7d1ed3830f69 Mon Sep 17 00:00:00 2001 From: Aleksandar Stefanovic Date: Mon, 6 Jul 2026 19:59:28 +0200 Subject: [PATCH 1/3] Python: Add agent-framework-agentsandbox connector --- python/PACKAGE_STATUS.md | 1 + python/packages/agentsandbox/LICENSE | 21 ++ python/packages/agentsandbox/README.md | 110 ++++++ python/packages/agentsandbox/TESTING.md | 273 +++++++++++++++ .../agent_framework_agentsandbox/__init__.py | 37 +++ .../_execute_code_tool.py | 312 ++++++++++++++++++ .../_instructions.py | 65 ++++ .../agent_framework_agentsandbox/_provider.py | 153 +++++++++ .../agent_framework_agentsandbox/py.typed | 0 python/packages/agentsandbox/pyproject.toml | 98 ++++++ .../tests/agentsandbox/__init__.py | 1 + .../agentsandbox/test_agentsandbox_codeact.py | 216 ++++++++++++ .../agent_framework/agentsandbox/__init__.py | 36 ++ .../agent_framework/agentsandbox/__init__.pyi | 11 + python/pyproject.toml | 1 + .../02-agents/context_providers/README.md | 1 + .../agentsandbox_codeact/README.md | 48 +++ .../agentsandbox_codeact.py | 153 +++++++++ 18 files changed, 1537 insertions(+) create mode 100644 python/packages/agentsandbox/LICENSE create mode 100644 python/packages/agentsandbox/README.md create mode 100644 python/packages/agentsandbox/TESTING.md create mode 100644 python/packages/agentsandbox/agent_framework_agentsandbox/__init__.py create mode 100644 python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py create mode 100644 python/packages/agentsandbox/agent_framework_agentsandbox/_instructions.py create mode 100644 python/packages/agentsandbox/agent_framework_agentsandbox/_provider.py create mode 100644 python/packages/agentsandbox/agent_framework_agentsandbox/py.typed create mode 100644 python/packages/agentsandbox/pyproject.toml create mode 100644 python/packages/agentsandbox/tests/agentsandbox/__init__.py create mode 100644 python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py create mode 100644 python/packages/core/agent_framework/agentsandbox/__init__.py create mode 100644 python/packages/core/agent_framework/agentsandbox/__init__.pyi create mode 100644 python/samples/02-agents/context_providers/agentsandbox_codeact/README.md create mode 100644 python/samples/02-agents/context_providers/agentsandbox_codeact/agentsandbox_codeact.py diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index c0080f0cbbf..3080f262068 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -17,6 +17,7 @@ Status is grouped into these buckets: | `agent-framework` | `python/` | `released` | | `agent-framework-a2a` | `python/packages/a2a` | `beta` | | `agent-framework-ag-ui` | `python/packages/ag-ui` | `rc` | +| `agent-framework-agentsandbox` | `python/packages/agentsandbox` | `alpha` | | `agent-framework-anthropic` | `python/packages/anthropic` | `beta` | | `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `alpha` | | `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` | diff --git a/python/packages/agentsandbox/LICENSE b/python/packages/agentsandbox/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/agentsandbox/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/agentsandbox/README.md b/python/packages/agentsandbox/README.md new file mode 100644 index 00000000000..f34519e9924 --- /dev/null +++ b/python/packages/agentsandbox/README.md @@ -0,0 +1,110 @@ +# agent-framework-agentsandbox + +agent-sandbox-backed CodeAct integration for Microsoft Agent Framework. Runs LLM-emitted Python inside an isolated Kubernetes Pod managed by the [`kubernetes-sigs/agent-sandbox`](https://github.com/kubernetes-sigs/agent-sandbox) controller — with persistent filesystem state across calls, `pip install` survival, optional gVisor / Kata isolation, and `SandboxWarmPool` for sub-second cold starts. + +> **Status: experimental / alpha.** Requires `k8s-agent-sandbox>=0.5.0`, the first release with the native async client (`AsyncSandboxClient` / `AsyncSandbox`) and the warm-pool claim API (`create_sandbox(warmpool=...)`). + +## When to use this + +`agent-framework-hyperlight` is the in-process WASM CodeAct backend — microsecond startup, snapshot-and-restore per call, ideal for short stateless computations. + +`agent-framework-agentsandbox` is the **remote, persistent, container-grade** alternative. Use it when the agent needs to `pip install` a package and use it next call, read/produce real files, use libraries that don't run in WASM, run for minutes against a working dataset, be hardened with gVisor / Kata, or run on infrastructure the team already operates. + +## Quick start + +### Context provider (recommended) + +```python +from agent_framework import Agent +from agent_framework.ollama import OllamaChatClient +from agent_framework.agentsandbox import AgentSandboxCodeActProvider +from k8s_agent_sandbox.models import SandboxDirectConnectionConfig + +async with AgentSandboxCodeActProvider( + warmpool="python-sandbox-pool", + namespace="default", + # The async client reaches the Pod through the sandbox-router. Point it at a + # local `kubectl port-forward svc/sandbox-router-svc 8080:8080`, an + # in-cluster service, or a Gateway. Local kubectl-tunnel mode is not + # supported by the async client. + connection_config=SandboxDirectConnectionConfig(api_url="http://localhost:8080"), + shutdown_after_seconds=30 * 60, +) as codeact: + agent = Agent( + client=OllamaChatClient(), + instructions="Use execute_code for computation; print the answer.", + context_providers=[codeact], + ) + result = await agent.run("What is the 30th Fibonacci number?") + print(result.text) +``` + +The provider lazily claims one Sandbox Pod from the warm pool on the first `execute_code` call and reuses it across every run on this agent until you exit the `async with` block. + +### Standalone tool + +```python +from agent_framework import Agent +from agent_framework.agentsandbox import AgentSandboxExecuteCodeTool + +execute_code = AgentSandboxExecuteCodeTool( + warmpool="python-sandbox-pool", + namespace="default", + connection_config=..., +) +try: + agent = Agent(client=..., tools=[my_direct_tool, execute_code]) + # ... +finally: + await execute_code.close() +``` + +## Configuration + +Most knobs live where they belong in Kubernetes, not on the Python constructor: + +- **CPU / memory / image / volumes / runtime class / security context** → `SandboxTemplate.spec.podTemplate`. +- **Pre-warming / pooling** → `SandboxWarmPool` (references the template); the provider claims from it by name. +- **Network egress allow-listing** → Kubernetes `NetworkPolicy`. +- **File mounts** → template `Volumes`, or `sandbox.files.write` / `read` at runtime. + +Python-side knobs: + +| Argument | Default | Purpose | +|---|---|---| +| `warmpool` (required) | — | `SandboxWarmPool` to claim from | +| `namespace` | `"default"` | Kubernetes namespace | +| `connection_config` (required) | — | `SandboxDirectConnectionConfig` / `SandboxGatewayConnectionConfig` / `SandboxInClusterConnectionConfig` | +| `shutdown_after_seconds` | `None` | Safety net: controller auto-deletes the claim after this TTL | +| `labels` | `None` | Extra Kubernetes labels on the claim | +| `approval_mode` | `"never_require"` | Framework's tool-approval gate | +| `python_command` | `"python3 -u"` | Interpreter invocation | +| `exec_timeout` | `120` | Per-call subprocess timeout (seconds) | + +## How it talks to the sandbox + +It uses the agent-sandbox **async** SDK (`AsyncSandboxClient` / `AsyncSandbox`) directly — no thread offloading inside Agent Framework's async run loop. Each `execute_code` call: + +1. `await sandbox.files.write("/app/_agent_sandbox_exec.py", code)` — ships the source as a file (sidesteps shell quoting; the model gets real tracebacks with file/line info). +2. `await sandbox.commands.run("python3 -u _agent_sandbox_exec.py")` via the Pod's `/execute` endpoint. +3. Maps `stdout` / `stderr` / `exit_code` into `Content` objects: stdout as text, stderr appended on success, or `Content.from_error(...)` on non-zero exit. + +### Runtime notes + +- **`pip install` location.** The reference `python-runtime-sandbox` image runs as a non-root user whose `HOME` is read-only, so a plain `pip install ` fails with a permission error (it cannot write `/.local` / `/.cache`). The working directory `/app` is writable — install under it and extend `sys.path`, e.g. `pip install --target=/app/.pkgs ` then `sys.path.insert(0, "/app/.pkgs")`. Packages installed this way persist on the Pod across `execute_code` calls. A runtime image with a writable `HOME` makes a plain `pip install` work too; this is an image choice, not a limitation of the integration. +- **Router authentication.** This package reaches the Pod through the agent-sandbox `sandbox-router`. Recent router builds require auth (`ROUTER_AUTH_TOKEN`) and refuse to start otherwise, unless `ALLOW_UNAUTHENTICATED_ROUTER=true` is set. The `k8s-agent-sandbox` SDK does not send a token yet, so the router must run in unauthenticated mode for this integration to reach it. + +## Comparison with `agent-framework-hyperlight` + +| | `hyperlight` | `agentsandbox` | +|---|---|---| +| Runtime | In-process WASM micro-VM | Kubernetes Pod | +| Startup | Microseconds | Seconds (sub-second with `SandboxWarmPool`) | +| State across calls | Snapshot-and-restore — clean each call | Persistent — filesystem + packages stay | +| Isolation | VM-level, no Linux kernel | Container + optional gVisor / Kata | +| Pool / pre-warm | In-process registry | `SandboxWarmPool` controller | + +## Testing + +See [`TESTING.md`](TESTING.md) for a full local end-to-end walkthrough (kind cluster + Ollama, no API keys) and [`samples/02-agents/context_providers/agentsandbox_codeact/`](../../samples/02-agents/context_providers/agentsandbox_codeact/) for a runnable demo. + diff --git a/python/packages/agentsandbox/TESTING.md b/python/packages/agentsandbox/TESTING.md new file mode 100644 index 00000000000..b39ecb33ed8 --- /dev/null +++ b/python/packages/agentsandbox/TESTING.md @@ -0,0 +1,273 @@ +# Testing `agent-framework-agentsandbox` locally + +This walks through a full local end-to-end test of the agent-sandbox CodeAct +integration: a local Kubernetes cluster (kind) running the agent-sandbox +controller, plus a local Ollama model (no API key required). + +> **Version requirement.** This integration needs agent-sandbox **v0.5.0 or +> newer** — the first release with the native async client (`AsyncSandboxClient` +> / `AsyncSandbox`) and the warm-pool claim API (`create_sandbox(warmpool=...)`), +> plus the per-sandbox headless Service the router relies on. The package's +> `pyproject.toml` pins `k8s-agent-sandbox[async]>=0.5.0`, and the steps below +> install the controller from the v0.5.0 release manifests (no source build). + +The integration talks to the Pod through the **sandbox-router**. The async +client does not support `kubectl port-forward` tunnelling, so you run a +port-forward yourself and point the sample at it with +`SandboxDirectConnectionConfig`. + +## Prerequisites + +```bash +brew install kind kubectl +brew install --cask docker # Docker engine must be running +brew install ollama +# uv is already installed at ~/.local/bin from building the package +``` + +Clone agent-sandbox (for the router source and the `SandboxTemplate` manifest) +and reference your local clone of the `agent-framework` repository: + +```bash +git clone https://github.com/kubernetes-sigs/agent-sandbox.git +export AGENT_SANDBOX=$(pwd)/agent-sandbox +export AF=/path/to/agent-framework # root of this repository +export AGENT_SANDBOX_VERSION=v0.5.0 +``` + +--- + +## Step 1 — Create a kind cluster and install the controller + extensions + +The **extensions** manifest is required — the integration claims from a +`SandboxWarmPool`. + +```bash +kind create cluster --name agent-sandbox +kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${AGENT_SANDBOX_VERSION}/manifest.yaml" +kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${AGENT_SANDBOX_VERSION}/extensions.yaml" +kubectl -n agent-sandbox-system rollout status deploy --timeout=180s +``` + +--- + +## Step 2 — Build and load the sandbox-router image + +The router ships as source (not a release image), so build it once and load it +into the cluster. + +```bash +cd "$AGENT_SANDBOX/clients/python/agentic-sandbox-client/sandbox-router" +docker build -t sandbox-router:dev . +kind load docker-image sandbox-router:dev --name agent-sandbox +``` + +--- + +## Step 3 — Deploy the sandbox-router + +Substitute the local image, force `imagePullPolicy: Never`, and drop to one +replica for a laptop. + +```bash +cd "$AGENT_SANDBOX/clients/python/agentic-sandbox-client/sandbox-router" + +sed -e 's|${ROUTER_IMAGE}|sandbox-router:dev|' \ + -e 's|# imagePullPolicy: Never|imagePullPolicy: Never|' \ + -e 's|replicas: 2|replicas: 1|' \ + sandbox_router.yaml | kubectl apply -n default -f - + +# Allow unauthenticated access (see note below) and wait for the rollout. +kubectl -n default set env deploy/sandbox-router-deployment ALLOW_UNAUTHENTICATED_ROUTER=true +kubectl -n default rollout status deploy/sandbox-router-deployment --timeout=120s +``` + +> **Router authentication.** Recent `sandbox-router` builds refuse to start +> unless either `ROUTER_AUTH_TOKEN` is set (token-authenticated mode) or +> `ALLOW_UNAUTHENTICATED_ROUTER=true` is set (local/dev mode). The +> `k8s-agent-sandbox` Python SDK does not currently send an auth token, so the +> router must run in unauthenticated mode for the SDK — and therefore this +> integration — to reach it. We set `ALLOW_UNAUTHENTICATED_ROUTER=true` above. +> Without it the router pod enters `CrashLoopBackOff` and every request fails. +> (When the SDK gains token support, set `ROUTER_AUTH_TOKEN` instead and pass +> the matching token through the SDK.) + +> Everything lives in the `default` namespace because the sample defaults to +> `namespace="default"`. Keep router, template, warm pool, and sandboxes there. + +--- + +## Step 4 — Apply the SandboxTemplate and a SandboxWarmPool + +The template defines the Pod spec; the warm pool references the template and is +what the SDK claims from. The sample defaults to a pool named +`python-sandbox-pool`. + +```bash +cd "$AGENT_SANDBOX/clients/python/agentic-sandbox-client" + +# Template (placeholders for name/namespace): +SANDBOX_TEMPLATE_NAME=python-sandbox-template \ +SANDBOX_NAMESPACE=default \ + envsubst < python-sandbox-template.yaml | kubectl apply -f - + +# Warm pool that references the template: +kubectl apply -f - <<'EOF' +apiVersion: extensions.agents.x-k8s.io/v1beta1 +kind: SandboxWarmPool +metadata: + name: python-sandbox-pool + namespace: default +spec: + replicas: 1 # pre-warm one Pod; use 0 for purely on-demand + sandboxTemplateRef: + name: python-sandbox-template +EOF + +kubectl -n default get sandboxtemplate,sandboxwarmpool +``` + +(No `envsubst`? `brew install gettext` or hand-edit the two placeholders.) + +The template points at the public image +`us-central1-docker.pkg.dev/k8s-staging-images/agent-sandbox/python-runtime-sandbox:latest-main`. +Optionally pre-pull + load it so the first claim is not slowed by an in-cluster +pull: + +```bash +docker pull us-central1-docker.pkg.dev/k8s-staging-images/agent-sandbox/python-runtime-sandbox:latest-main +kind load docker-image us-central1-docker.pkg.dev/k8s-staging-images/agent-sandbox/python-runtime-sandbox:latest-main --name agent-sandbox +``` + +--- + +## Step 5 — Port-forward the router + +The async client connects via `SandboxDirectConnectionConfig`, so expose the +router on localhost in a **separate terminal** (keep it running): + +```bash +kubectl -n default port-forward svc/sandbox-router-svc 8080:8080 +``` + +--- + +## Step 6 — Start Ollama and pull a tool-capable model + +```bash +ollama serve & # if not already running as a service +ollama pull qwen2.5 # solid function-calling; ~4.7GB +export OLLAMA_MODEL=qwen2.5 +# export OLLAMA_HOST=http://localhost:11434 # default; only set if different +``` + +--- + +## Step 7 — Install the agent-framework workspace + +```bash +cd "$AF/python" +export PATH="$HOME/.local/bin:$PATH" # uv +uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit +``` + +The `agentsandbox` package depends on `k8s-agent-sandbox[async]>=0.5.0`, so this +pulls the async-capable SDK from PyPI. + +--- + +## Step 8 — Run the sample + +```bash +cd "$AF/python" +export OLLAMA_MODEL=qwen2.5 +uv run --no-sync python \ + samples/02-agents/context_providers/agentsandbox_codeact/agentsandbox_codeact.py +``` + +Optional overrides: + +```bash +export AGENT_SANDBOX_WARMPOOL=python-sandbox-pool +export AGENT_SANDBOX_NAMESPACE=default +export AGENT_SANDBOX_ROUTER_URL=http://localhost:8080 +``` + +--- + +## Step 9 — What success looks like + +- A `SandboxClaim` / `Sandbox` / Pod appears while the sample runs: + + ```bash + kubectl -n default get sandboxclaim,sandbox,pod + ``` + +- The console shows the model emitting an `execute_code` block, the sandbox + returning stdout, and the agent summarizing — for both prompts. Turn 1 writes + `/app/fib.txt`; turn 2 reads it back in a *separate* `execute_code` call, + proving the Pod's filesystem persists across calls. + +- When the script exits (the `async with` block closes), the SandboxClaim and + its Pod are deleted automatically. The `shutdown_after_seconds=30*60` in the + sample is a controller-side safety net if the process is killed. + +> **`pip install` inside the sandbox.** The reference `python-runtime-sandbox` +> image runs as a non-root user whose `HOME` (`/`) is read-only, so a plain +> `pip install ` fails with a permission error while trying to write +> `/.local` or `/.cache`. The working directory `/app` *is* writable, so install +> into a path under it and add that path to `sys.path` — for example +> `pip install --target=/app/.pkgs `, then +> `sys.path.insert(0, "/app/.pkgs")`. Packages installed this way persist on the +> Pod across `execute_code` calls (that location survives because the Pod is +> long-lived), which is one of the advantages of this backend. A runtime image +> with a writable `HOME` would let a plain `pip install` work too; that is an +> image choice, not a limitation of the integration. + +Run the package unit tests too (no cluster needed): + +```bash +cd "$AF/python" +uv run --no-sync pytest packages/agentsandbox/tests -v +``` + +### Optional: sanity-check the cluster with the SDK's own e2e first + +If the sample fails, isolate the cluster from the integration by running the +SDK's own test (no LLM, none of this package's code): + +```bash +cd "$AGENT_SANDBOX/clients/python/agentic-sandbox-client" +python3 -m venv /tmp/agentsbx-sdk && source /tmp/agentsbx-sdk/bin/activate +pip install -q -e . +python test_client.py --namespace default --warmpool-name python-sandbox-pool +``` + +--- + +## Cleanup + +```bash +kubectl -n default delete sandboxwarmpool/python-sandbox-pool --ignore-not-found +kubectl -n default delete sandboxtemplate/python-sandbox-template --ignore-not-found +kubectl -n default delete deploy/sandbox-router-deployment svc/sandbox-router-svc --ignore-not-found + +# Then drop the whole cluster: +kind delete cluster --name agent-sandbox +``` + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| Router pod in `CrashLoopBackOff`; logs say `ROUTER_AUTH_TOKEN must be set` | The router refuses to start unauthenticated by default. Set `ALLOW_UNAUTHENTICATED_ROUTER=true` on the deployment (Step 3) for local/dev, or `ROUTER_AUTH_TOKEN` for token mode. The SDK does not send a token yet, so it needs the router in unauthenticated mode. | +| `pip install` fails with `Permission denied: '/.local'` inside the sandbox | The runtime image's `HOME` is read-only. Install under `/app` instead: `pip install --target=/app/.pkgs ` then `sys.path.insert(0, "/app/.pkgs")`. See the note in Step 9. | +| `ValueError: connection_config is required` | The async client needs `SandboxDirectConnectionConfig` / `SandboxGatewayConnectionConfig` / `SandboxInClusterConnectionConfig`. Local-tunnel is not supported by the async client. | +| `502 Bad Gateway` / `Name or service not known` from the router | Controller older than v0.5.0 — the per-sandbox headless Service is missing. Install the v0.5.0 (or newer) release manifests (Step 1). | +| `create_sandbox() got an unexpected keyword 'warmpool'` | SDK older than 0.5.0. Confirm with `uv run --no-sync python -c "import inspect; from k8s_agent_sandbox import AsyncSandboxClient; print(inspect.signature(AsyncSandboxClient.create_sandbox))"`. | +| `SandboxWarmPoolNotFoundError` | The warm pool is missing or in another namespace. It must match the provider's `namespace` (`default`). | +| Claim never becomes Ready | First image pull is slow. Pre-pull + load the runtime image (end of Step 4), or raise `sandbox_ready_timeout`. | +| Model never calls `execute_code` | The Ollama model is too weak at tool calling. Use `qwen2.5` or `llama3.1:8b`. | +| `kubectl` talks to the wrong cluster | `kubectl config use-context kind-agent-sandbox`. | diff --git a/python/packages/agentsandbox/agent_framework_agentsandbox/__init__.py b/python/packages/agentsandbox/agent_framework_agentsandbox/__init__.py new file mode 100644 index 00000000000..ff7a4972234 --- /dev/null +++ b/python/packages/agentsandbox/agent_framework_agentsandbox/__init__.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""agent-sandbox CodeAct integration for Microsoft Agent Framework. + +This package exposes two classes that plug into the Agent Framework's +``ContextProvider`` / ``FunctionTool`` extension points: + +* :class:`AgentSandboxCodeActProvider` — drop into ``Agent(context_providers=[...])`` + to auto-inject the ``execute_code`` tool and CodeAct system instructions on + every run. +* :class:`AgentSandboxExecuteCodeTool` — the underlying ``FunctionTool`` for + callers that want to add the tool directly without a provider. + +Each invocation of ``execute_code`` runs LLM-emitted Python inside a Kubernetes +Pod managed by the `kubernetes-sigs/agent-sandbox +`_ controller. State persists +for the lifetime of the provider: the Pod's filesystem and any +``pip install``-ed packages are available across calls, even though each call +runs as a fresh ``python3`` process. Call ``await provider.close()`` (or use +the provider as an async context manager) to terminate the Pod when done. +""" + +import importlib.metadata + +from ._execute_code_tool import AgentSandboxExecuteCodeTool +from ._provider import AgentSandboxCodeActProvider + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "AgentSandboxCodeActProvider", + "AgentSandboxExecuteCodeTool", + "__version__", +] diff --git a/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py b/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py new file mode 100644 index 00000000000..6a21b092d47 --- /dev/null +++ b/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py @@ -0,0 +1,312 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""The ``execute_code`` FunctionTool implementation. + +Each call ships the LLM-emitted Python to a long-lived agent-sandbox pod and +runs it as a fresh ``python3`` process. The pod is claimed lazily on the first +invocation and reused for the lifetime of the tool instance, so filesystem +state and any ``pip install``-ed packages persist across calls while each +call still gets a clean interpreter. + +The integration is async-native: it uses the agent-sandbox SDK's +``AsyncSandboxClient`` / ``AsyncSandbox`` directly, so no thread offloading is +needed inside Agent Framework's async run loop. ``AsyncSandboxClient`` requires +an explicit ``connection_config`` (``SandboxDirectConnectionConfig``, +``SandboxGatewayConnectionConfig``, or ``SandboxInClusterConnectionConfig``); +the synchronous ``kubectl port-forward`` tunnel mode is not supported by the +async client. + +The runtime image used by the reference ``python-sandbox-template`` runs the +request command via ``shlex.split`` + ``subprocess.run`` without a shell, which +makes ``python3 -c ''`` brittle for non-trivial programs (quoting, +multi-line strings, embedded shell metacharacters). Writing the code to a file +via the sandbox's files API and running ``python3 -u `` avoids all of +that and gives the model real tracebacks with file/line info. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from agent_framework import Content, FunctionTool +from agent_framework._tools import ApprovalMode +from k8s_agent_sandbox import AsyncSandboxClient +from k8s_agent_sandbox.async_sandbox import AsyncSandbox +from k8s_agent_sandbox.models import SandboxConnectionConfig, SandboxTracerConfig + +from ._instructions import build_codeact_instructions, build_execute_code_description + +logger = logging.getLogger(__name__) + +EXECUTE_CODE_TOOL_NAME = "execute_code" +DEFAULT_CODE_FILENAME = "_agent_sandbox_exec.py" +DEFAULT_PYTHON_COMMAND = "python3 -u" +DEFAULT_EXEC_TIMEOUT_SECONDS = 120 +DEFAULT_SANDBOX_READY_TIMEOUT_SECONDS = 180 + +EXECUTE_CODE_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "title": "_ExecuteCodeInput", + "properties": { + "code": { + "type": "string", + "title": "Code", + "description": ( + "Python source to execute in an isolated agent-sandbox pod. End with `print(...)` to surface results." + ), + }, + }, + "required": ["code"], +} + + +class AgentSandboxExecuteCodeTool(FunctionTool): + """``execute_code`` tool backed by a Kubernetes agent-sandbox Pod. + + One sandbox is created lazily on the first call and reused for every + subsequent invocation on this tool instance. State (working directory, + installed packages, long-lived files) persists across calls; Python + module-level globals do not, because each call is a fresh ``python3`` + process. + + Always call :meth:`close` (or use the parent provider as an async context + manager) when finished — the sandbox claim and its underlying Pod will + keep running on the cluster until then. + """ + + def __init__( + self, + *, + warmpool: str, + namespace: str = "default", + connection_config: SandboxConnectionConfig | None = None, + tracer_config: SandboxTracerConfig | None = None, + sandbox_ready_timeout: int = DEFAULT_SANDBOX_READY_TIMEOUT_SECONDS, + shutdown_after_seconds: int | None = None, + labels: dict[str, str] | None = None, + approval_mode: ApprovalMode | None = None, + python_command: str = DEFAULT_PYTHON_COMMAND, + exec_timeout: int = DEFAULT_EXEC_TIMEOUT_SECONDS, + code_filename: str = DEFAULT_CODE_FILENAME, + _client: AsyncSandboxClient[Any] | None = None, + ) -> None: + """Initialize the tool. + + Keyword Args: + warmpool: Name of the ``SandboxWarmPool`` the Pod is claimed from. + Required. The warm pool references a ``SandboxTemplate`` and may + pre-warm pods (``replicas > 0``) to remove cold-start latency, + or act as a plain on-demand pool (``replicas: 0``). + namespace: Kubernetes namespace that holds the warm pool and where + the Pod will run. + connection_config: How the SDK reaches the Pod. Required — the + async client supports ``SandboxDirectConnectionConfig``, + ``SandboxGatewayConnectionConfig``, and + ``SandboxInClusterConnectionConfig`` (not the synchronous + ``kubectl port-forward`` tunnel mode). + tracer_config: OpenTelemetry tracing config forwarded to the SDK. + sandbox_ready_timeout: Seconds to wait for the Pod to become Ready + after the claim is created. + shutdown_after_seconds: If set, the controller will auto-delete + the sandbox after this many seconds. Safety net so forgotten + tools do not leak Pods. + labels: Optional Kubernetes labels to attach to the claim. + approval_mode: Whether or not approval is required to run this + tool. Defaults to ``"never_require"``. + python_command: How to invoke the interpreter inside the Pod. + Default ``"python3 -u"`` keeps stdout unbuffered. + exec_timeout: Per-call timeout for the ``python3`` subprocess + inside the Pod, in seconds. + code_filename: Scratch file name (under ``/app/``) used to ship the + model's source to the Pod. The file is overwritten on each + call. + _client: Internal hook for tests and shared-client scenarios. When + provided, the tool does not own the client and will not delete + it on close. + + Raises: + ValueError: If ``warmpool`` is empty. + """ + if not warmpool: + raise ValueError("warmpool is required.") + + super().__init__( + name=EXECUTE_CODE_TOOL_NAME, + description=build_execute_code_description( + warmpool=warmpool, + namespace=namespace, + ), + approval_mode=approval_mode or "never_require", + func=self._run_code, + input_model=EXECUTE_CODE_INPUT_SCHEMA, + ) + + self._warmpool = warmpool + self._namespace = namespace + self._connection_config = connection_config + self._tracer_config = tracer_config + self._sandbox_ready_timeout = sandbox_ready_timeout + self._shutdown_after_seconds = shutdown_after_seconds + self._labels = labels + self._python_command = python_command + self._exec_timeout = exec_timeout + self._code_filename = code_filename + + # Track whether the client was injected by a caller (e.g. shared across + # providers) or owned by this tool. Only owned clients get cleaned up. + self._client: AsyncSandboxClient[Any] | None = _client + self._owns_client = _client is None + self._sandbox: AsyncSandbox | None = None + self._sandbox_lock = asyncio.Lock() + self._closed = False + + @property + def warmpool(self) -> str: + """Name of the ``SandboxWarmPool`` this tool claims sandboxes from.""" + return self._warmpool + + @property + def namespace(self) -> str: + """Kubernetes namespace where claims are created.""" + return self._namespace + + def build_instructions(self) -> str: + """Return the CodeAct system-prompt fragment for this tool.""" + return build_codeact_instructions() + + async def _ensure_sandbox(self) -> AsyncSandbox: + """Lazily claim and return the underlying sandbox, idempotently.""" + if self._closed: + raise RuntimeError( + "AgentSandboxExecuteCodeTool has been closed; create a new tool or provider instance.", + ) + sandbox = self._sandbox + if sandbox is not None and sandbox.is_active: + return sandbox + + async with self._sandbox_lock: + sandbox = self._sandbox + if sandbox is not None and sandbox.is_active: + return sandbox + + if self._client is None: + # AsyncSandboxClient validates connection_config in __init__ and + # raises a descriptive ValueError if it is None or a tunnel + # config, so no extra guard is needed here. + self._client = AsyncSandboxClient( + connection_config=self._connection_config, + tracer_config=self._tracer_config, + ) + # ``k8s-agent-sandbox`` ships no type information, so its generic + # ``create_sandbox`` return is opaque to the type checker. Treat the + # client as untyped at this boundary (as the in-tree hyperlight + # package does for its sandbox) and annotate the concrete handle. + client: Any = self._client + new_sandbox: AsyncSandbox = await client.create_sandbox( + warmpool=self._warmpool, + namespace=self._namespace, + sandbox_ready_timeout=self._sandbox_ready_timeout, + labels=self._labels, + shutdown_after_seconds=self._shutdown_after_seconds, + ) + self._sandbox = new_sandbox + logger.info( + "agent-sandbox '%s' claimed from warm pool '%s' in namespace '%s'.", + new_sandbox.sandbox_id, + self._warmpool, + self._namespace, + ) + return new_sandbox + + async def _run_code(self, *, code: str) -> list[Content]: + """Execute ``code`` inside the sandbox Pod and return tool ``Content``.""" + sandbox = await self._ensure_sandbox() + + # Write the source to a file inside the sandbox, then exec it. + # Two HTTP round-trips, but it sidesteps shell quoting entirely and + # the model gets a real path in its tracebacks. The filename is + # reused so we do not accumulate cruft in /app over a long session. + files = sandbox.files + commands = sandbox.commands + if files is None or commands is None: + raise RuntimeError("Sandbox connection is not active.") + + await files.write(self._code_filename, code) + result = await commands.run( + f"{self._python_command} {self._code_filename}", + self._exec_timeout, + ) + return _build_execution_contents( + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + ) + + async def close(self) -> None: + """Terminate the sandbox Pod and clean up the client (if owned). + + Safe to call multiple times. After ``close`` returns, the tool can no + longer execute code — construct a new instance if needed. + """ + if self._closed: + return + self._closed = True + + sandbox = self._sandbox + self._sandbox = None + if sandbox is not None: + try: + # terminate() closes the connection and deletes the SandboxClaim. + await sandbox.terminate() + except Exception: + logger.exception( + "Failed to terminate sandbox '%s'.", + sandbox.sandbox_id, + ) + + client = self._client + self._client = None + if self._owns_client and client is not None: + try: + # close() shuts down any remaining sandbox connections and the + # underlying async k8s API client (httpx / kubernetes_asyncio). + await client.close() + except Exception: + logger.exception("Failed to clean up AsyncSandboxClient on close.") + + +def _build_execution_contents( + *, + stdout: str, + stderr: str, + exit_code: int, +) -> list[Content]: + """Convert an ``ExecutionResult`` into the framework's ``Content`` list shape.""" + normalized_stdout = (stdout or "").replace("\r\n", "\n").rstrip("\n") + normalized_stderr = (stderr or "").replace("\r\n", "\n").rstrip("\n") + + outputs: list[Content] = [] + if normalized_stdout: + outputs.append(Content.from_text(normalized_stdout)) + + if exit_code == 0: + if normalized_stderr: + outputs.append(Content.from_text(normalized_stderr)) + if not outputs: + outputs.append( + Content.from_text("Code executed successfully without output."), + ) + return outputs + + # Non-zero exit: surface as a structured error so the model can decide to + # retry / adjust rather than treating it as plain output. + error_details = normalized_stderr or f"Process exited with code {exit_code}." + outputs.append( + Content.from_error( + message="Execution error", + error_details=error_details, + ), + ) + return outputs diff --git a/python/packages/agentsandbox/agent_framework_agentsandbox/_instructions.py b/python/packages/agentsandbox/agent_framework_agentsandbox/_instructions.py new file mode 100644 index 00000000000..a83c750a84d --- /dev/null +++ b/python/packages/agentsandbox/agent_framework_agentsandbox/_instructions.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""CodeAct prompt and tool-description builders. + +The wording is deliberately small. The goal is to teach the model two things and +nothing more: prefer ``execute_code`` for computation, and end the program with +``print(...)`` (the sandbox does not return the value of the last expression). + +The persistent-state note matters because agent-sandbox differs from a +snapshot-and-restore sandbox: filesystem state and installed packages carry +over between calls, while Python module-level globals do not (each call is a +fresh interpreter process). Surfacing this lets the model use the filesystem +as a working store between steps without expecting in-memory continuity. +""" + +from __future__ import annotations + + +def build_codeact_instructions(*, working_directory: str = "/app") -> str: + """Return the CodeAct system-prompt fragment injected by the provider. + + Args: + working_directory: Absolute path of the working directory inside the + sandbox Pod. The agent-sandbox runtime image used by the sample + ``python-sandbox-template`` runs commands from ``/app``, so that is + the default. + + Returns: + A short CodeAct instruction fragment suitable for + :meth:`agent_framework.SessionContext.extend_instructions`. + """ + return ( + "You have one primary tool: execute_code.\n" + "\n" + "Prefer a single execute_code call per user turn when possible. " + "To surface results, end the code with `print(...)`; the sandbox does " + "not return the value of the last expression.\n" + "\n" + f"The working directory inside the sandbox is `{working_directory}/`. " + "Files written there persist across calls, and any packages you " + "`pip install` remain installed for the lifetime of the session. " + "Module-level Python globals do not persist between calls — each " + "execute_code invocation is a fresh `python3` process.\n" + ) + + +def build_execute_code_description(*, warmpool: str, namespace: str) -> str: + """Return the description shown to the model on the ``execute_code`` tool. + + Args: + warmpool: The name of the ``SandboxWarmPool`` the pod is claimed from. + Surfaced to the model so reasoning traces can mention which sandbox + backed the call. + namespace: Kubernetes namespace that holds the warm pool. + + Returns: + Tool description suitable for :class:`agent_framework.FunctionTool`. + """ + return ( + "Execute a Python program inside an isolated Kubernetes sandbox pod " + f"(agent-sandbox warm pool `{warmpool}`, namespace `{namespace}`). " + "Returns stdout, plus stderr or a structured error on non-zero exit. " + "Use `print(...)` to surface results; the working directory is `/app/` " + "and persists across calls." + ) diff --git a/python/packages/agentsandbox/agent_framework_agentsandbox/_provider.py b/python/packages/agentsandbox/agent_framework_agentsandbox/_provider.py new file mode 100644 index 00000000000..34e26e7c2ac --- /dev/null +++ b/python/packages/agentsandbox/agent_framework_agentsandbox/_provider.py @@ -0,0 +1,153 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""ContextProvider that wires the agent-sandbox CodeAct surface into an Agent. + +The provider holds a single :class:`AgentSandboxExecuteCodeTool` and, on every +``before_run``, injects it together with a short CodeAct system-prompt fragment. +Because the tool owns one sandbox Pod for the lifetime of the provider, all +runs against the same agent share the same working directory and any +``pip install``-ed packages — which is what makes multi-step agent loops feel +natural without per-call setup cost. + +Always close the provider when done (use it as an async context manager, or +call ``await provider.close()``) so the cluster-side Pod and SandboxClaim are +deleted. +""" + +from __future__ import annotations + +from types import TracebackType +from typing import Any + +from agent_framework import AgentSession, ContextProvider, SessionContext, SupportsAgentRun +from agent_framework._tools import ApprovalMode +from k8s_agent_sandbox import AsyncSandboxClient +from k8s_agent_sandbox.models import SandboxConnectionConfig, SandboxTracerConfig + +from ._execute_code_tool import ( + DEFAULT_EXEC_TIMEOUT_SECONDS, + DEFAULT_SANDBOX_READY_TIMEOUT_SECONDS, + AgentSandboxExecuteCodeTool, +) + + +class AgentSandboxCodeActProvider(ContextProvider): + """Inject an agent-sandbox-backed ``execute_code`` tool into every agent run. + + Pass this to ``Agent(context_providers=[...])``. The provider creates one + Kubernetes Sandbox Pod (lazily, on the first ``execute_code`` call) and + reuses it across every run on the agent. + """ + + DEFAULT_SOURCE_ID = "agent_sandbox_codeact" + + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + *, + warmpool: str, + namespace: str = "default", + connection_config: SandboxConnectionConfig | None = None, + tracer_config: SandboxTracerConfig | None = None, + sandbox_ready_timeout: int = DEFAULT_SANDBOX_READY_TIMEOUT_SECONDS, + shutdown_after_seconds: int | None = None, + labels: dict[str, str] | None = None, + approval_mode: ApprovalMode | None = None, + python_command: str | None = None, + exec_timeout: int = DEFAULT_EXEC_TIMEOUT_SECONDS, + _client: AsyncSandboxClient[Any] | None = None, + ) -> None: + """Initialize the provider. + + Args: + source_id: Stable identifier used by Agent Framework to attribute + the provider's contributions to a run. Override only if you + register more than one CodeAct provider on the same agent. + + Keyword Args: + warmpool: Name of the ``SandboxWarmPool`` to claim from. Required. + The warm pool references a ``SandboxTemplate`` and may pre-warm + pods (``replicas > 0``) to remove cold-start latency, or act as + a plain on-demand pool (``replicas: 0``). + namespace: Kubernetes namespace that holds the warm pool and where + the sandbox Pod will run. + connection_config: How the SDK reaches the Pod. Required — the async + client supports ``SandboxDirectConnectionConfig``, + ``SandboxGatewayConnectionConfig``, and + ``SandboxInClusterConnectionConfig`` (not the synchronous + ``kubectl port-forward`` tunnel mode). + tracer_config: OpenTelemetry tracing config forwarded to the SDK. + sandbox_ready_timeout: Seconds to wait for the Pod to become Ready + after the claim is created. + shutdown_after_seconds: If set, the controller will auto-delete + the sandbox after this many seconds. Useful as a safety net so + forgotten providers do not leak Pods. + labels: Optional Kubernetes labels to attach to the claim. + approval_mode: Mirrors the Hyperlight provider's parameter so an + existing CodeAct agent definition can switch backends by + changing the import. + python_command: How to invoke the interpreter inside the Pod. + Defaults to ``"python3 -u"`` when ``None``. + exec_timeout: Per-call timeout for the ``python3`` subprocess + inside the Pod, in seconds. + _client: Internal hook for tests and advanced usage that want to + inject a pre-built :class:`k8s_agent_sandbox.AsyncSandboxClient`. + When provided, the provider does not own the client and will + not delete it on close. + """ + super().__init__(source_id) + tool_kwargs: dict[str, Any] = { + "warmpool": warmpool, + "namespace": namespace, + "connection_config": connection_config, + "tracer_config": tracer_config, + "sandbox_ready_timeout": sandbox_ready_timeout, + "shutdown_after_seconds": shutdown_after_seconds, + "labels": labels, + "approval_mode": approval_mode, + "exec_timeout": exec_timeout, + "_client": _client, + } + # Only forward python_command if the caller explicitly set one, so the + # tool keeps its own default. + if python_command is not None: + tool_kwargs["python_command"] = python_command + + self._execute_code_tool = AgentSandboxExecuteCodeTool(**tool_kwargs) + + @property + def execute_code_tool(self) -> AgentSandboxExecuteCodeTool: + """The underlying ``execute_code`` :class:`FunctionTool` instance.""" + return self._execute_code_tool + + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Inject the CodeAct instructions and the execute_code tool for this run.""" + context.extend_instructions( + self.source_id, + self._execute_code_tool.build_instructions(), + ) + context.extend_tools(self.source_id, [self._execute_code_tool]) + + async def close(self) -> None: + """Terminate the sandbox Pod owned by this provider. Idempotent.""" + await self._execute_code_tool.close() + + async def __aenter__(self) -> AgentSandboxCodeActProvider: + """Enter the async context manager.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + """Exit the async context manager and terminate the sandbox.""" + await self.close() diff --git a/python/packages/agentsandbox/agent_framework_agentsandbox/py.typed b/python/packages/agentsandbox/agent_framework_agentsandbox/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/agentsandbox/pyproject.toml b/python/packages/agentsandbox/pyproject.toml new file mode 100644 index 00000000000..540716dfa50 --- /dev/null +++ b/python/packages/agentsandbox/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "agent-framework-agentsandbox" +description = "agent-sandbox CodeAct integration for Microsoft Agent Framework. Runs LLM-emitted Python inside a Kubernetes Pod managed by the kubernetes-sigs/agent-sandbox controller." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b260507" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.3.0,<2", + # 0.5.0 is the first release with the native async client + # (AsyncSandboxClient / AsyncSandbox) and the warm-pool claim API + # (create_sandbox(warmpool=...)). The [async] extra pulls in httpx and + # kubernetes_asyncio. + "k8s-agent-sandbox[async]>=0.5.0,<1", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_agentsandbox"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_agentsandbox"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_agentsandbox" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_agentsandbox --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/agentsandbox/tests/agentsandbox/__init__.py b/python/packages/agentsandbox/tests/agentsandbox/__init__.py new file mode 100644 index 00000000000..2a50eae8941 --- /dev/null +++ b/python/packages/agentsandbox/tests/agentsandbox/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py b/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py new file mode 100644 index 00000000000..dbd5dd585ce --- /dev/null +++ b/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py @@ -0,0 +1,216 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext, SupportsAgentRun +from k8s_agent_sandbox import AsyncSandboxClient + +from agent_framework_agentsandbox import AgentSandboxCodeActProvider, AgentSandboxExecuteCodeTool + +WARMPOOL = "python-sandbox-pool" + + +def _fake_execution_result(stdout: str = "", stderr: str = "", exit_code: int = 0) -> SimpleNamespace: + return SimpleNamespace(stdout=stdout, stderr=stderr, exit_code=exit_code) + + +def _fake_sandbox(execution_result: SimpleNamespace) -> tuple[SimpleNamespace, dict[str, Any]]: + """Build a fake AsyncSandbox + a recorder dict so tests can assert on calls. + + The real SDK exposes ``files.write`` / ``commands.run`` / ``terminate`` as + coroutines, so the fakes are ``async def`` to match. + """ + recorder: dict[str, Any] = {"writes": [], "runs": [], "terminated": False} + + async def fake_write(path: str, content: str, timeout: int = 60) -> None: + recorder["writes"].append((path, content, timeout)) + + async def fake_run(command: str, timeout: int = 60) -> SimpleNamespace: + recorder["runs"].append((command, timeout)) + return execution_result + + async def fake_terminate() -> None: + recorder["terminated"] = True + + sandbox = SimpleNamespace( + sandbox_id="sandbox-test", + is_active=True, + files=SimpleNamespace(write=fake_write), + commands=SimpleNamespace(run=fake_run), + terminate=fake_terminate, + ) + return sandbox, recorder + + +class _FakeAsyncClient: + """Fake AsyncSandboxClient that hands back a canned sandbox on create_sandbox().""" + + def __init__(self, sandbox: SimpleNamespace) -> None: + self._sandbox = sandbox + self.create_kwargs: dict[str, Any] | None = None + self.closed = False + + async def create_sandbox(self, **kwargs: Any) -> SimpleNamespace: + self.create_kwargs = kwargs + return self._sandbox + + async def close(self) -> None: + self.closed = True + + +def test_tool_is_a_function_tool() -> None: + tool = AgentSandboxExecuteCodeTool(warmpool=WARMPOOL) + assert isinstance(tool, FunctionTool) + assert tool.name == "execute_code" + assert tool.approval_mode == "never_require" + + +def test_provider_is_a_context_provider() -> None: + provider = AgentSandboxCodeActProvider(warmpool=WARMPOOL) + assert isinstance(provider, ContextProvider) + assert provider.source_id == AgentSandboxCodeActProvider.DEFAULT_SOURCE_ID + assert provider.execute_code_tool.name == "execute_code" + + +def test_tool_requires_warmpool() -> None: + with pytest.raises(ValueError, match="warmpool is required"): + AgentSandboxExecuteCodeTool(warmpool="") + + +async def test_run_code_happy_path() -> None: + sandbox, recorder = _fake_sandbox(_fake_execution_result(stdout="832040\n", exit_code=0)) + client = _FakeAsyncClient(sandbox) + + tool = AgentSandboxExecuteCodeTool( + warmpool=WARMPOOL, + namespace="my-ns", + shutdown_after_seconds=300, + _client=cast("AsyncSandboxClient", client), + ) + + code = "print(sum(range(10)))" + contents = await tool._run_code(code=code) + + # The tool should pass warmpool/namespace/timeout/labels/shutdown through. + assert client.create_kwargs == { + "warmpool": WARMPOOL, + "namespace": "my-ns", + "sandbox_ready_timeout": 180, + "labels": None, + "shutdown_after_seconds": 300, + } + # One write (the source file), one exec (python3 -u ). + assert recorder["writes"] == [("_agent_sandbox_exec.py", code, 60)] + assert recorder["runs"] == [("python3 -u _agent_sandbox_exec.py", 120)] + # Stdout surfaces as a single text Content with trailing newline stripped. + assert len(contents) == 1 + assert contents[0].type == "text" + assert contents[0].text == "832040" + + +async def test_run_code_surfaces_error_on_nonzero_exit() -> None: + sandbox, _ = _fake_sandbox( + _fake_execution_result(stderr="Traceback...\nValueError: bad input\n", exit_code=1), + ) + client = _FakeAsyncClient(sandbox) + + tool = AgentSandboxExecuteCodeTool(warmpool=WARMPOOL, _client=cast("AsyncSandboxClient", client)) + contents = await tool._run_code(code="raise ValueError('bad input')") + + error_contents = [c for c in contents if c.type == "error"] + assert error_contents, "expected an error Content" + assert "ValueError" in (error_contents[0].error_details or "") + + +async def test_run_code_empty_output_returns_friendly_text() -> None: + sandbox, _ = _fake_sandbox(_fake_execution_result()) + client = _FakeAsyncClient(sandbox) + + tool = AgentSandboxExecuteCodeTool(warmpool=WARMPOOL, _client=cast("AsyncSandboxClient", client)) + contents = await tool._run_code(code="x = 1") + + assert len(contents) == 1 + assert contents[0].type == "text" + assert contents[0].text is not None + assert "without output" in contents[0].text + + +async def test_provider_before_run_injects_instructions_and_tool() -> None: + sandbox, _ = _fake_sandbox(_fake_execution_result(stdout="ok\n")) + client = _FakeAsyncClient(sandbox) + + provider = AgentSandboxCodeActProvider( + warmpool=WARMPOOL, + _client=cast("AsyncSandboxClient", client), + ) + + class FakeSessionContext: + def __init__(self) -> None: + self.instructions: dict[str, str] = {} + self.tools: dict[str, list[Any]] = {} + + def extend_instructions(self, source_id: str, instructions: str) -> None: + self.instructions[source_id] = instructions + + def extend_tools(self, source_id: str, tools: list[Any]) -> None: + self.tools[source_id] = list(tools) + + ctx = FakeSessionContext() + await provider.before_run( + agent=cast("SupportsAgentRun", None), + session=cast("AgentSession", None), + context=cast("SessionContext", ctx), + state={}, + ) + + assert AgentSandboxCodeActProvider.DEFAULT_SOURCE_ID in ctx.instructions + assert "execute_code" in ctx.instructions[AgentSandboxCodeActProvider.DEFAULT_SOURCE_ID] + injected = ctx.tools[AgentSandboxCodeActProvider.DEFAULT_SOURCE_ID] + assert len(injected) == 1 + assert injected[0].name == "execute_code" + + +async def test_close_terminates_sandbox_and_rejects_further_calls() -> None: + sandbox, recorder = _fake_sandbox(_fake_execution_result(stdout="hi\n")) + client = _FakeAsyncClient(sandbox) + + provider = AgentSandboxCodeActProvider( + warmpool=WARMPOOL, + _client=cast("AsyncSandboxClient", client), + ) + # The concrete tool type exposes the internal _run_code; cast because the + # pydantic mypy plugin widens the property's return to FunctionTool. + tool = cast("AgentSandboxExecuteCodeTool", provider.execute_code_tool) + # Trigger sandbox creation. + await tool._run_code(code="print('hi')") + assert recorder["terminated"] is False + + await provider.close() + assert recorder["terminated"] is True + # An injected client is not owned, so close() must leave it open for the caller. + assert client.closed is False + # Idempotent. + await provider.close() + + with pytest.raises(RuntimeError, match="has been closed"): + await tool._run_code(code="print('hi')") + + +async def test_provider_as_async_context_manager_cleans_up() -> None: + sandbox, recorder = _fake_sandbox(_fake_execution_result(stdout="hi\n")) + client = _FakeAsyncClient(sandbox) + + async with AgentSandboxCodeActProvider( + warmpool=WARMPOOL, + _client=cast("AsyncSandboxClient", client), + ) as provider: + tool = cast("AgentSandboxExecuteCodeTool", provider.execute_code_tool) + await tool._run_code(code="print('hi')") + + assert recorder["terminated"] is True + # Injected client is not owned, so it is left open for the caller. + assert client.closed is False diff --git a/python/packages/core/agent_framework/agentsandbox/__init__.py b/python/packages/core/agent_framework/agentsandbox/__init__.py new file mode 100644 index 00000000000..0d223ccd182 --- /dev/null +++ b/python/packages/core/agent_framework/agentsandbox/__init__.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""agent-sandbox CodeAct namespace for optional Agent Framework connectors. + +This module lazily re-exports objects from: +- ``agent-framework-agentsandbox`` + +Supported classes: +- AgentSandboxCodeActProvider +- AgentSandboxExecuteCodeTool +""" + +import importlib +from typing import Any + +IMPORT_PATH = "agent_framework_agentsandbox" +PACKAGE_NAME = "agent-framework-agentsandbox" +_IMPORTS = [ + "AgentSandboxCodeActProvider", + "AgentSandboxExecuteCodeTool", +] + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + try: + return getattr(importlib.import_module(IMPORT_PATH), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" + ) from exc + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") + + +def __dir__() -> list[str]: + return _IMPORTS diff --git a/python/packages/core/agent_framework/agentsandbox/__init__.pyi b/python/packages/core/agent_framework/agentsandbox/__init__.pyi new file mode 100644 index 00000000000..c0752c2c922 --- /dev/null +++ b/python/packages/core/agent_framework/agentsandbox/__init__.pyi @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_agentsandbox import ( + AgentSandboxCodeActProvider, + AgentSandboxExecuteCodeTool, +) + +__all__ = [ + "AgentSandboxCodeActProvider", + "AgentSandboxExecuteCodeTool", +] diff --git a/python/pyproject.toml b/python/pyproject.toml index 0bd5466ba14..f8f265f2dac 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -75,6 +75,7 @@ agent-framework = { workspace = true } agent-framework-core = { workspace = true } agent-framework-a2a = { workspace = true } agent-framework-ag-ui = { workspace = true } +agent-framework-agentsandbox = { workspace = true } agent-framework-azure-ai-search = { workspace = true } agent-framework-azure-cosmos = { workspace = true } agent-framework-anthropic = { workspace = true } diff --git a/python/samples/02-agents/context_providers/README.md b/python/samples/02-agents/context_providers/README.md index e49a472f397..5e519435142 100644 --- a/python/samples/02-agents/context_providers/README.md +++ b/python/samples/02-agents/context_providers/README.md @@ -7,6 +7,7 @@ These samples demonstrate how to use context providers to enrich agent conversat | File / Folder | Description | |---------------|-------------| | [`simple_context_provider.py`](simple_context_provider.py) | Implement a custom context provider by extending `ContextProvider` to extract and inject structured user information across turns. | +| [`agentsandbox_codeact/`](agentsandbox_codeact/) | Run LLM-emitted Python via `AgentSandboxCodeActProvider` inside a Kubernetes Pod (kubernetes-sigs/agent-sandbox), with filesystem + `pip install` state persisting across `execute_code` calls. Uses a local Ollama model. See its own [README](agentsandbox_codeact/README.md). | | [`azure_ai_foundry_memory.py`](azure_ai_foundry_memory.py) | Use `FoundryMemoryProvider` to add semantic memory — automatically retrieves, searches, and stores memories via Azure AI Foundry. | | [`file_access_data_processing/`](file_access_data_processing/) | Use `FileAccessProvider` with `FileSystemAgentFileStore` to give an agent read/write/search access to a folder of CSV data files. See its own [README](file_access_data_processing/README.md). | | [`azure_ai_search/`](azure_ai_search/) | Retrieval Augmented Generation (RAG) with Azure AI Search in semantic and agentic modes. See its own [README](azure_ai_search/README.md). | diff --git a/python/samples/02-agents/context_providers/agentsandbox_codeact/README.md b/python/samples/02-agents/context_providers/agentsandbox_codeact/README.md new file mode 100644 index 00000000000..e5e8bdad490 --- /dev/null +++ b/python/samples/02-agents/context_providers/agentsandbox_codeact/README.md @@ -0,0 +1,48 @@ +# agent-sandbox CodeAct context provider + +Demonstrates `AgentSandboxCodeActProvider`: every `execute_code` call runs +LLM-emitted Python inside a Kubernetes Pod claimed from a `SandboxWarmPool` and +managed by the [`kubernetes-sigs/agent-sandbox`](https://github.com/kubernetes-sigs/agent-sandbox) +controller. Unlike an in-process snapshot-and-restore sandbox, the Pod's working +directory and any `pip install`-ed packages persist across calls. + +This sample uses `OllamaChatClient`, so it runs **without any API key**. + +## Installation + +```bash +pip install agent-framework agent-framework-agentsandbox agent-framework-ollama --pre +``` + +## Prerequisites + +- A Kubernetes cluster with the agent-sandbox controller installed, plus a + `SandboxTemplate` and a `SandboxWarmPool` (default name `python-sandbox-pool`). +- The `sandbox-router` reachable on `localhost:8080` (the async client uses a + direct connection, so run `kubectl -n default port-forward svc/sandbox-router-svc 8080:8080`). +- Ollama running locally with a tool-capable model, e.g. `ollama pull qwen2.5`. + +See the package's [`TESTING.md`](../../../../packages/agentsandbox/TESTING.md) +for a complete local walkthrough. + +## Configuration + +| Environment variable | Default | Purpose | +|---|---|---| +| `OLLAMA_MODEL` | (Ollama default) | Model to use; must support tool calling | +| `AGENT_SANDBOX_WARMPOOL` | `python-sandbox-pool` | Warm pool to claim from | +| `AGENT_SANDBOX_NAMESPACE` | `default` | Namespace holding the warm pool | +| `AGENT_SANDBOX_ROUTER_URL` | `http://localhost:8080` | Router endpoint | + +## Run + +```bash +python agentsandbox_codeact.py +``` + +See [`agentsandbox_codeact.py`](agentsandbox_codeact.py) for the annotated example. + +## Related + +- [`code_act/`](../code_act/) — the same `ContextProvider` shape backed by an + in-process Hyperlight WASM sandbox (stateless per call). diff --git a/python/samples/02-agents/context_providers/agentsandbox_codeact/agentsandbox_codeact.py b/python/samples/02-agents/context_providers/agentsandbox_codeact/agentsandbox_codeact.py new file mode 100644 index 00000000000..595dbb8830b --- /dev/null +++ b/python/samples/02-agents/context_providers/agentsandbox_codeact/agentsandbox_codeact.py @@ -0,0 +1,153 @@ +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable + +from agent_framework import Agent, FunctionInvocationContext, function_middleware +from agent_framework.agentsandbox import AgentSandboxCodeActProvider +from agent_framework.ollama import OllamaChatClient +from dotenv import load_dotenv +from k8s_agent_sandbox.models import SandboxDirectConnectionConfig + +""" +Prerequisites + +1. A running Kubernetes cluster with the agent-sandbox controller installed + (https://agent-sandbox.sigs.k8s.io/docs/getting_started/). +2. A ``SandboxWarmPool`` (and the ``SandboxTemplate`` it references) applied to + the cluster. See this sample's ``../../../../packages/agentsandbox/TESTING.md`` + for ready-to-apply manifests; by default the sample claims from a warm pool + named ``python-sandbox-pool`` in the ``default`` namespace. +3. The ``sandbox-router`` deployed in the cluster, reachable on localhost. The + async client does not support ``kubectl port-forward`` tunnelling, so run a + port-forward yourself in a separate terminal and point the sample at it: + + kubectl -n default port-forward svc/sandbox-router-svc 8080:8080 + + then ``SandboxDirectConnectionConfig(api_url="http://localhost:8080")`` (the + default below) routes through it. +4. Ollama installed and running locally (https://ollama.com/). Pull a model + that supports function calling, for example: + + ollama pull qwen2.5 + +5. Optional environment variables: + + export OLLAMA_HOST=http://localhost:11434 + export OLLAMA_MODEL=qwen2.5 + export AGENT_SANDBOX_WARMPOOL=python-sandbox-pool + export AGENT_SANDBOX_NAMESPACE=default + export AGENT_SANDBOX_ROUTER_URL=http://localhost:8080 +""" + +load_dotenv() + +_CYAN = "\033[36m" +_YELLOW = "\033[33m" +_GREEN = "\033[32m" +_DIM = "\033[2m" +_RESET = "\033[0m" + + +class _ColoredFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + return f"{_DIM}{super().format(record)}{_RESET}" + + +logging.basicConfig(level=logging.WARNING) +logging.getLogger().handlers[0].setFormatter( + _ColoredFormatter("[%(asctime)s] %(levelname)s: %(message)s"), +) + + +@function_middleware +async def log_function_calls( + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], +) -> None: + import time + + function_name = context.function.name + arguments = context.arguments if isinstance(context.arguments, dict) else {} + + if function_name == "execute_code" and "code" in arguments: + print(f"\n{_YELLOW}{'─' * 60}") + print("▶ execute_code") + print(f"{'─' * 60}{_RESET}") + print(arguments["code"]) + print(f"{_YELLOW}{'─' * 60}{_RESET}") + else: + pairs = ", ".join(f"{name}={value!r}" for name, value in arguments.items()) + print(f"\n{_YELLOW}▶ {function_name}({pairs}){_RESET}") + + start = time.perf_counter() + await call_next() + elapsed = time.perf_counter() - start + + result = context.result + if function_name == "execute_code" and isinstance(result, list): + for output in result: + if output.type == "text" and output.text: + print(f"{_GREEN}stdout:\n{output.text}{_RESET}") + elif output.type == "error" and output.error_details: + print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}") + else: + print(f"{_YELLOW}◀ {function_name} → {result!r}{_RESET}") + + print(f"{_DIM} ({elapsed:.4f}s){_RESET}") + + +async def main() -> None: + warmpool = os.environ.get("AGENT_SANDBOX_WARMPOOL", "python-sandbox-pool") + namespace = os.environ.get("AGENT_SANDBOX_NAMESPACE", "default") + router_url = os.environ.get("AGENT_SANDBOX_ROUTER_URL", "http://localhost:8080") + + async with AgentSandboxCodeActProvider( + warmpool=warmpool, + namespace=namespace, + # The async client reaches the Pod through the sandbox-router. Point it + # at a local `kubectl port-forward svc/sandbox-router-svc 8080:8080`. + connection_config=SandboxDirectConnectionConfig(api_url=router_url), + shutdown_after_seconds=30 * 60, + ) as codeact: + agent = Agent( + client=OllamaChatClient(), + name="AgentSandboxCodeActAgent", + instructions=( + "You are a careful Python assistant. When a user asks for a " + "computation or data manipulation, write a small Python " + "snippet and run it with `execute_code` to get an exact " + "answer, then explain the result briefly. Use only the Python " + "standard library — third-party packages are not installed in " + "the sandbox unless you `pip install` them yourself." + ), + context_providers=[codeact], + middleware=[log_function_calls], + ) + + session = agent.create_session() + + prompts = [ + ( + "Compute the 30th Fibonacci number using only the standard " + "library. Save just the number to /app/fib.txt and print it." + ), + ( + "Read the number back from /app/fib.txt, compute its prime " + "factorization using only the standard library, save the " + "factors to /app/factors.txt, then print the number and its " + "factors. (The file is still there because the sandbox Pod " + "persists across calls.)" + ), + ] + print(f"{_CYAN}{'=' * 60}") + print("agent-sandbox CodeAct provider sample") + print(f"{'=' * 60}{_RESET}") + for prompt in prompts: + print(f"\n{_CYAN}User: {prompt}{_RESET}") + result = await agent.run(prompt, session=session) + print(f"{_CYAN}Agent: {result.text}{_RESET}") + + +if __name__ == "__main__": + asyncio.run(main()) From e687d8068c9189de07801de50ca9a6bf28510143 Mon Sep 17 00:00:00 2001 From: Aleksandar Stefanovic Date: Tue, 7 Jul 2026 21:34:14 +0200 Subject: [PATCH 2/3] Resolving copilot comments --- python/packages/agentsandbox/README.md | 4 +- python/packages/agentsandbox/TESTING.md | 12 +++++- .../_execute_code_tool.py | 39 ++++++++++++------ .../agentsandbox/test_agentsandbox_codeact.py | 41 +++++++++++++++++++ 4 files changed, 80 insertions(+), 16 deletions(-) diff --git a/python/packages/agentsandbox/README.md b/python/packages/agentsandbox/README.md index f34519e9924..c35d67547f8 100644 --- a/python/packages/agentsandbox/README.md +++ b/python/packages/agentsandbox/README.md @@ -85,8 +85,8 @@ Python-side knobs: It uses the agent-sandbox **async** SDK (`AsyncSandboxClient` / `AsyncSandbox`) directly — no thread offloading inside Agent Framework's async run loop. Each `execute_code` call: -1. `await sandbox.files.write("/app/_agent_sandbox_exec.py", code)` — ships the source as a file (sidesteps shell quoting; the model gets real tracebacks with file/line info). -2. `await sandbox.commands.run("python3 -u _agent_sandbox_exec.py")` via the Pod's `/execute` endpoint. +1. `await sandbox.files.write("_agent_sandbox_exec.py", code)` — ships the source as a file into the Pod's `/app` working directory (sidesteps shell quoting; the model gets real tracebacks with file/line info). +2. `await sandbox.commands.run("python3 -u _agent_sandbox_exec.py")` via the Pod's `/execute` endpoint (commands run from `/app`). 3. Maps `stdout` / `stderr` / `exit_code` into `Content` objects: stdout as text, stderr appended on success, or `Content.from_error(...)` on non-zero exit. ### Runtime notes diff --git a/python/packages/agentsandbox/TESTING.md b/python/packages/agentsandbox/TESTING.md index b39ecb33ed8..f9a82c57c26 100644 --- a/python/packages/agentsandbox/TESTING.md +++ b/python/packages/agentsandbox/TESTING.md @@ -103,6 +103,13 @@ The template defines the Pod spec; the warm pool references the template and is what the SDK claims from. The sample defaults to a pool named `python-sandbox-pool`. +> **`service: true` is required.** The router reaches each Pod by its DNS name +> (`..svc.cluster.local`), which needs a per-sandbox headless +> Service. That Service is only created when the sandbox has `spec.service: +> true`, configured on the **template** (`SandboxTemplate.spec.service`). The +> upstream `python-sandbox-template.yaml` does not set it, so the step below +> patches it in — without it the router returns `502 Bad Gateway`. + ```bash cd "$AGENT_SANDBOX/clients/python/agentic-sandbox-client" @@ -111,6 +118,9 @@ SANDBOX_TEMPLATE_NAME=python-sandbox-template \ SANDBOX_NAMESPACE=default \ envsubst < python-sandbox-template.yaml | kubectl apply -f - +# Enable the per-sandbox headless Service the router resolves: +kubectl patch sandboxtemplate python-sandbox-template --type merge -p '{"spec":{"service":true}}' + # Warm pool that references the template: kubectl apply -f - <<'EOF' apiVersion: extensions.agents.x-k8s.io/v1beta1 @@ -265,7 +275,7 @@ kind delete cluster --name agent-sandbox | Router pod in `CrashLoopBackOff`; logs say `ROUTER_AUTH_TOKEN must be set` | The router refuses to start unauthenticated by default. Set `ALLOW_UNAUTHENTICATED_ROUTER=true` on the deployment (Step 3) for local/dev, or `ROUTER_AUTH_TOKEN` for token mode. The SDK does not send a token yet, so it needs the router in unauthenticated mode. | | `pip install` fails with `Permission denied: '/.local'` inside the sandbox | The runtime image's `HOME` is read-only. Install under `/app` instead: `pip install --target=/app/.pkgs ` then `sys.path.insert(0, "/app/.pkgs")`. See the note in Step 9. | | `ValueError: connection_config is required` | The async client needs `SandboxDirectConnectionConfig` / `SandboxGatewayConnectionConfig` / `SandboxInClusterConnectionConfig`. Local-tunnel is not supported by the async client. | -| `502 Bad Gateway` / `Name or service not known` from the router | Controller older than v0.5.0 — the per-sandbox headless Service is missing. Install the v0.5.0 (or newer) release manifests (Step 1). | +| `502 Bad Gateway` / `Name or service not known` from the router | No per-sandbox headless Service, so its DNS name does not resolve. Set `spec.service: true` on the `SandboxTemplate` (Step 4) and recreate the warm pool so its Pods inherit it. | | `create_sandbox() got an unexpected keyword 'warmpool'` | SDK older than 0.5.0. Confirm with `uv run --no-sync python -c "import inspect; from k8s_agent_sandbox import AsyncSandboxClient; print(inspect.signature(AsyncSandboxClient.create_sandbox))"`. | | `SandboxWarmPoolNotFoundError` | The warm pool is missing or in another namespace. It must match the provider's `namespace` (`default`). | | Claim never becomes Ready | First image pull is slow. Pre-pull + load the runtime image (end of Step 4), or raise `sandbox_ready_timeout`. | diff --git a/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py b/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py index 6a21b092d47..157952e2d04 100644 --- a/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py +++ b/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py @@ -178,15 +178,19 @@ def build_instructions(self) -> str: async def _ensure_sandbox(self) -> AsyncSandbox: """Lazily claim and return the underlying sandbox, idempotently.""" - if self._closed: - raise RuntimeError( - "AgentSandboxExecuteCodeTool has been closed; create a new tool or provider instance.", - ) + # Fast path outside the lock; the authoritative check is inside it. sandbox = self._sandbox - if sandbox is not None and sandbox.is_active: + if not self._closed and sandbox is not None and sandbox.is_active: return sandbox async with self._sandbox_lock: + # Re-check under the lock so a concurrent close() cannot slip in + # between the check and the create and leave the new sandbox + # orphaned (close() flips _closed while holding this same lock). + if self._closed: + raise RuntimeError( + "AgentSandboxExecuteCodeTool has been closed; create a new tool or provider instance.", + ) sandbox = self._sandbox if sandbox is not None and sandbox.is_active: return sandbox @@ -250,12 +254,23 @@ async def close(self) -> None: Safe to call multiple times. After ``close`` returns, the tool can no longer execute code — construct a new instance if needed. """ - if self._closed: - return - self._closed = True + # Flip _closed and detach the sandbox/client references under the same + # lock _ensure_sandbox() uses. If a claim is in flight, this blocks + # until it finishes and then picks up the freshly created sandbox, so + # nothing is orphaned; if creation has not started, _ensure_sandbox() + # sees _closed under the lock and refuses to create. + async with self._sandbox_lock: + if self._closed: + return + self._closed = True + sandbox = self._sandbox + self._sandbox = None + client = self._client + self._client = None + owns_client = self._owns_client - sandbox = self._sandbox - self._sandbox = None + # Terminate outside the lock — these are network round-trips and the + # references are already detached. if sandbox is not None: try: # terminate() closes the connection and deletes the SandboxClaim. @@ -266,9 +281,7 @@ async def close(self) -> None: sandbox.sandbox_id, ) - client = self._client - self._client = None - if self._owns_client and client is not None: + if owns_client and client is not None: try: # close() shuts down any remaining sandbox connections and the # underlying async k8s API client (httpx / kubernetes_asyncio). diff --git a/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py b/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py index dbd5dd585ce..37f77a867ec 100644 --- a/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py +++ b/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from types import SimpleNamespace from typing import Any, cast @@ -214,3 +215,43 @@ async def test_provider_as_async_context_manager_cleans_up() -> None: assert recorder["terminated"] is True # Injected client is not owned, so it is left open for the caller. assert client.closed is False + + +async def test_close_during_in_flight_claim_terminates_new_sandbox() -> None: + """close() must not leak a sandbox that is being claimed concurrently.""" + sandbox, recorder = _fake_sandbox(_fake_execution_result(stdout="hi\n")) + started = asyncio.Event() + release = asyncio.Event() + + class _SlowClient: + def __init__(self) -> None: + self.closed = False + + async def create_sandbox(self, **kwargs: Any) -> Any: + started.set() + await release.wait() # hold the sandbox lock while close() blocks on it + return sandbox + + async def close(self) -> None: + self.closed = True + + slow_client = _SlowClient() + provider = AgentSandboxCodeActProvider( + warmpool=WARMPOOL, + _client=cast("AsyncSandboxClient", slow_client), + ) + tool = cast("AgentSandboxExecuteCodeTool", provider.execute_code_tool) + + claim = asyncio.create_task(tool._ensure_sandbox()) + await started.wait() # _ensure_sandbox holds the lock, awaiting create_sandbox + close_task = asyncio.create_task(provider.close()) # blocks on the same lock + await asyncio.sleep(0) # let close_task reach the lock + release.set() # let the claim finish + await claim + await close_task + + # The freshly claimed sandbox was terminated rather than orphaned. + assert recorder["terminated"] is True + # A subsequent claim is refused now that the tool is closed. + with pytest.raises(RuntimeError, match="has been closed"): + await tool._ensure_sandbox() From 59680793e6823d6298cef8a0df0ae40bbece8631 Mon Sep 17 00:00:00 2001 From: Aleksandar Stefanovic Date: Fri, 10 Jul 2026 12:26:42 +0200 Subject: [PATCH 3/3] execute_code to write to unique per-call filename --- python/packages/agentsandbox/README.md | 4 +- .../_execute_code_tool.py | 27 ++--- .../agentsandbox/test_agentsandbox_codeact.py | 37 +++++-- python/uv.lock | 102 ++++++++++++++++++ 4 files changed, 148 insertions(+), 22 deletions(-) diff --git a/python/packages/agentsandbox/README.md b/python/packages/agentsandbox/README.md index c35d67547f8..7036c73a01d 100644 --- a/python/packages/agentsandbox/README.md +++ b/python/packages/agentsandbox/README.md @@ -85,8 +85,8 @@ Python-side knobs: It uses the agent-sandbox **async** SDK (`AsyncSandboxClient` / `AsyncSandbox`) directly — no thread offloading inside Agent Framework's async run loop. Each `execute_code` call: -1. `await sandbox.files.write("_agent_sandbox_exec.py", code)` — ships the source as a file into the Pod's `/app` working directory (sidesteps shell quoting; the model gets real tracebacks with file/line info). -2. `await sandbox.commands.run("python3 -u _agent_sandbox_exec.py")` via the Pod's `/execute` endpoint (commands run from `/app`). +1. `await sandbox.files.write("_agent_sandbox_exec_.py", code)` — ships the source as a file into the Pod's `/app` working directory under a per-call unique name, so concurrent `execute_code` calls in one turn never clobber each other (sidesteps shell quoting; the model gets real tracebacks with file/line info). +2. `await sandbox.commands.run("python3 -u _agent_sandbox_exec_.py")` via the Pod's `/execute` endpoint (commands run from `/app`). 3. Maps `stdout` / `stderr` / `exit_code` into `Content` objects: stdout as text, stderr appended on success, or `Content.from_error(...)` on non-zero exit. ### Runtime notes diff --git a/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py b/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py index 157952e2d04..cb17e6a00a9 100644 --- a/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py +++ b/python/packages/agentsandbox/agent_framework_agentsandbox/_execute_code_tool.py @@ -22,12 +22,18 @@ multi-line strings, embedded shell metacharacters). Writing the code to a file via the sandbox's files API and running ``python3 -u `` avoids all of that and gives the model real tracebacks with file/line info. + +Each invocation writes to a unique filename. Agent Framework runs a batch of +tool calls in one turn concurrently, so a shared scratch filename would let one +call overwrite another's source before it runs; a per-call name keeps +concurrent ``execute_code`` calls isolated. """ from __future__ import annotations import asyncio import logging +import uuid from typing import Any from agent_framework import Content, FunctionTool @@ -41,7 +47,7 @@ logger = logging.getLogger(__name__) EXECUTE_CODE_TOOL_NAME = "execute_code" -DEFAULT_CODE_FILENAME = "_agent_sandbox_exec.py" +CODE_FILENAME_PREFIX = "_agent_sandbox_exec" DEFAULT_PYTHON_COMMAND = "python3 -u" DEFAULT_EXEC_TIMEOUT_SECONDS = 120 DEFAULT_SANDBOX_READY_TIMEOUT_SECONDS = 180 @@ -89,7 +95,6 @@ def __init__( approval_mode: ApprovalMode | None = None, python_command: str = DEFAULT_PYTHON_COMMAND, exec_timeout: int = DEFAULT_EXEC_TIMEOUT_SECONDS, - code_filename: str = DEFAULT_CODE_FILENAME, _client: AsyncSandboxClient[Any] | None = None, ) -> None: """Initialize the tool. @@ -119,9 +124,6 @@ def __init__( Default ``"python3 -u"`` keeps stdout unbuffered. exec_timeout: Per-call timeout for the ``python3`` subprocess inside the Pod, in seconds. - code_filename: Scratch file name (under ``/app/``) used to ship the - model's source to the Pod. The file is overwritten on each - call. _client: Internal hook for tests and shared-client scenarios. When provided, the tool does not own the client and will not delete it on close. @@ -152,7 +154,6 @@ def __init__( self._labels = labels self._python_command = python_command self._exec_timeout = exec_timeout - self._code_filename = code_filename # Track whether the client was injected by a caller (e.g. shared across # providers) or owned by this tool. Only owned clients get cleaned up. @@ -228,18 +229,20 @@ async def _run_code(self, *, code: str) -> list[Content]: """Execute ``code`` inside the sandbox Pod and return tool ``Content``.""" sandbox = await self._ensure_sandbox() - # Write the source to a file inside the sandbox, then exec it. - # Two HTTP round-trips, but it sidesteps shell quoting entirely and - # the model gets a real path in its tracebacks. The filename is - # reused so we do not accumulate cruft in /app over a long session. + # Write the source to a file inside the sandbox, then exec it. Two HTTP + # round-trips, but it sidesteps shell quoting entirely and the model + # gets a real path in its tracebacks. The filename is unique per call so + # concurrent execute_code calls (Agent Framework batches tool calls in a + # turn) never clobber each other's source before it runs. files = sandbox.files commands = sandbox.commands if files is None or commands is None: raise RuntimeError("Sandbox connection is not active.") - await files.write(self._code_filename, code) + filename = f"{CODE_FILENAME_PREFIX}_{uuid.uuid4().hex}.py" + await files.write(filename, code) result = await commands.run( - f"{self._python_command} {self._code_filename}", + f"{self._python_command} {filename}", self._exec_timeout, ) return _build_execution_contents( diff --git a/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py b/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py index 37f77a867ec..78a6f92c2c3 100644 --- a/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py +++ b/python/packages/agentsandbox/tests/agentsandbox/test_agentsandbox_codeact.py @@ -104,15 +104,35 @@ async def test_run_code_happy_path() -> None: "labels": None, "shutdown_after_seconds": 300, } - # One write (the source file), one exec (python3 -u ). - assert recorder["writes"] == [("_agent_sandbox_exec.py", code, 60)] - assert recorder["runs"] == [("python3 -u _agent_sandbox_exec.py", 120)] + # One write (the source file), one exec that runs the file just written. + assert len(recorder["writes"]) == 1 + write_path, write_code, write_timeout = recorder["writes"][0] + assert write_path.startswith("_agent_sandbox_exec_") and write_path.endswith(".py") + assert write_code == code + assert write_timeout == 60 + assert recorder["runs"] == [(f"python3 -u {write_path}", 120)] # Stdout surfaces as a single text Content with trailing newline stripped. assert len(contents) == 1 assert contents[0].type == "text" assert contents[0].text == "832040" +async def test_concurrent_run_code_uses_distinct_filenames() -> None: + """Two execute_code calls in one turn must not clobber each other's file.""" + sandbox, recorder = _fake_sandbox(_fake_execution_result(stdout="ok\n")) + client = _FakeAsyncClient(sandbox) + tool = AgentSandboxExecuteCodeTool(warmpool=WARMPOOL, _client=cast("AsyncSandboxClient", client)) + + await asyncio.gather(tool._run_code(code="print(1)"), tool._run_code(code="print(2)")) + + write_paths = [w[0] for w in recorder["writes"]] + run_cmds = [r[0] for r in recorder["runs"]] + assert len(write_paths) == 2 + assert write_paths[0] != write_paths[1] # each call gets its own scratch file + # Every run targets exactly the file its own call wrote. + assert set(run_cmds) == {f"python3 -u {path}" for path in write_paths} + + async def test_run_code_surfaces_error_on_nonzero_exit() -> None: sandbox, _ = _fake_sandbox( _fake_execution_result(stderr="Traceback...\nValueError: bad input\n", exit_code=1), @@ -183,9 +203,10 @@ async def test_close_terminates_sandbox_and_rejects_further_calls() -> None: warmpool=WARMPOOL, _client=cast("AsyncSandboxClient", client), ) - # The concrete tool type exposes the internal _run_code; cast because the - # pydantic mypy plugin widens the property's return to FunctionTool. - tool = cast("AgentSandboxExecuteCodeTool", provider.execute_code_tool) + # Reach the internal _run_code via the private attribute (the public + # execute_code_tool property is widened to FunctionTool by the pydantic + # mypy plugin, which hides _run_code). + tool = provider._execute_code_tool # Trigger sandbox creation. await tool._run_code(code="print('hi')") assert recorder["terminated"] is False @@ -209,7 +230,7 @@ async def test_provider_as_async_context_manager_cleans_up() -> None: warmpool=WARMPOOL, _client=cast("AsyncSandboxClient", client), ) as provider: - tool = cast("AgentSandboxExecuteCodeTool", provider.execute_code_tool) + tool = provider._execute_code_tool await tool._run_code(code="print('hi')") assert recorder["terminated"] is True @@ -240,7 +261,7 @@ async def close(self) -> None: warmpool=WARMPOOL, _client=cast("AsyncSandboxClient", slow_client), ) - tool = cast("AgentSandboxExecuteCodeTool", provider.execute_code_tool) + tool = provider._execute_code_tool claim = asyncio.create_task(tool._ensure_sandbox()) await started.wait() # _ensure_sandbox holds the lock, awaiting create_sandbox diff --git a/python/uv.lock b/python/uv.lock index 1e29e68513f..14a2999a823 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -32,6 +32,7 @@ members = [ "agent-framework", "agent-framework-a2a", "agent-framework-ag-ui", + "agent-framework-agentsandbox", "agent-framework-anthropic", "agent-framework-azure-ai-search", "agent-framework-azure-contentunderstanding", @@ -225,6 +226,21 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "agent-framework-agentsandbox" +version = "1.0.0b260507" +source = { editable = "packages/agentsandbox" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "k8s-agent-sandbox", extra = ["async"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "k8s-agent-sandbox", extras = ["async"], specifier = ">=0.5.0,<1" }, +] + [[package]] name = "agent-framework-anthropic" version = "1.0.0b260709" @@ -2417,6 +2433,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/b6/dba9f93a4daf9a0e05f78300e9be338a13ae272b9aaa0cafe4bce602831c/durabletask_azuremanaged-1.7.2-py3-none-any.whl", hash = "sha256:90c938c29d20aa20e06eecb03ef480a3d8d0d6f5620c9b773fa38b7691c66a74", size = 25644, upload-time = "2026-07-09T21:54:13.301Z" }, ] +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -3679,6 +3704,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "k8s-agent-sandbox" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "kubernetes", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "prometheus-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/85/d4bbd50df3a9ef7bfe43f6c3b1e17cb1cd933594b3c8e343b86c6c385c97/k8s_agent_sandbox-0.5.1.tar.gz", hash = "sha256:fb9873c114d206e88d6375c7d3c459785a80322a0937c13919682bef92e1bf6c", size = 125524, upload-time = "2026-07-09T23:35:15.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/72/61419c29f5e7525ecb6a87a0c3bcdee586f3e0ea11da24b2fb34681f5066/k8s_agent_sandbox-0.5.1-py3-none-any.whl", hash = "sha256:a9916d8f8adc95dded087fb22048ef1891074e02380f471e72631c99fc1a13ad", size = 77258, upload-time = "2026-07-09T23:35:13.811Z" }, +] + +[package.optional-dependencies] +async = [ + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "kubernetes-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -3803,6 +3849,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] +[[package]] +name = "kubernetes" +version = "36.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "durationpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests-oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "websocket-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/57/8b538af5076bc3372949d76f70ba3449bdfe52f9e6488170fa5d4f7cbe70/kubernetes-36.0.2.tar.gz", hash = "sha256:03551fcb49cae1f708f63624041e37403545b7aaed10cbf54e2b01a37a5438e3", size = 2336738, upload-time = "2026-06-01T18:20:30.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/5c160dbdef7123f8cc97fd8ece7e0198627a426a2a49614845e9086feb8d/kubernetes-36.0.2-py2.py3-none-any.whl", hash = "sha256:faf9b5241b58de0c4a5069f2a0ffc8ac06fece7215156cd3d3ba081a78a858b6", size = 4617568, upload-time = "2026-06-01T18:20:28.737Z" }, +] + +[[package]] +name = "kubernetes-asyncio" +version = "33.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/5f/c175f86b92ff5f19444e3be1423819491ae9859d1f6f7d83d404eab8b10d/kubernetes_asyncio-33.3.0.tar.gz", hash = "sha256:4c59cd4c99b197995ef38ef0c8ff45aab24b84830ebf0ddcb67355caea9674c9", size = 1124931, upload-time = "2025-08-11T21:39:37.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/20/90985f53c141e6f3464b7295a617ffd36574168861882f9291847d09f9b1/kubernetes_asyncio-33.3.0-py3-none-any.whl", hash = "sha256:25e6e265932ebb1aeecbdb30a107dbef3ee0bcd388ed12d092be70915733982b", size = 2174591, upload-time = "2025-08-11T21:39:35.697Z" }, +] + [[package]] name = "langfuse" version = "4.13.2" @@ -5941,6 +6025,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -8510,6 +8603,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "15.0.1"