agentkernel-sdk 0.7.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentkernel_sdk-0.7.1/.gitignore +4 -0
- agentkernel_sdk-0.7.1/PKG-INFO +102 -0
- agentkernel_sdk-0.7.1/README.md +71 -0
- agentkernel_sdk-0.7.1/examples/async_example.py +31 -0
- agentkernel_sdk-0.7.1/examples/quickstart.py +16 -0
- agentkernel_sdk-0.7.1/examples/sandbox_session.py +16 -0
- agentkernel_sdk-0.7.1/examples/streaming.py +19 -0
- agentkernel_sdk-0.7.1/pyproject.toml +57 -0
- agentkernel_sdk-0.7.1/src/agentkernel/__init__.py +51 -0
- agentkernel_sdk-0.7.1/src/agentkernel/_config.py +29 -0
- agentkernel_sdk-0.7.1/src/agentkernel/async_client.py +255 -0
- agentkernel_sdk-0.7.1/src/agentkernel/client.py +241 -0
- agentkernel_sdk-0.7.1/src/agentkernel/errors.py +54 -0
- agentkernel_sdk-0.7.1/src/agentkernel/py.typed +0 -0
- agentkernel_sdk-0.7.1/src/agentkernel/sse.py +49 -0
- agentkernel_sdk-0.7.1/src/agentkernel/types.py +81 -0
- agentkernel_sdk-0.7.1/tests/test_async_client.py +64 -0
- agentkernel_sdk-0.7.1/tests/test_client.py +171 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentkernel-sdk
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: Python SDK for agentkernel — run AI coding agents in secure, isolated microVMs
|
|
5
|
+
Project-URL: Homepage, https://github.com/thrashr888/agentkernel
|
|
6
|
+
Project-URL: Repository, https://github.com/thrashr888/agentkernel/tree/main/sdk/python
|
|
7
|
+
Author-email: Paul Thrasher <thrashr888@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: agent,agentkernel,ai,firecracker,microvm,sandbox
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx-sse>=0.4
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# agentkernel
|
|
33
|
+
|
|
34
|
+
Python SDK for [agentkernel](https://github.com/thrashr888/agentkernel) — run AI coding agents in secure, isolated microVMs.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install agentkernel-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Python 3.10+.
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from agentkernel import AgentKernel
|
|
48
|
+
|
|
49
|
+
with AgentKernel() as client:
|
|
50
|
+
result = client.run(["echo", "hello"])
|
|
51
|
+
print(result.output) # "hello\n"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Async
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from agentkernel import AsyncAgentKernel
|
|
58
|
+
|
|
59
|
+
async with AsyncAgentKernel() as client:
|
|
60
|
+
result = await client.run(["echo", "hello"])
|
|
61
|
+
print(result.output)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Sandbox Sessions
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
with AgentKernel() as client:
|
|
68
|
+
with client.sandbox("test", image="python:3.12-alpine") as sb:
|
|
69
|
+
sb.run(["pip", "install", "numpy"])
|
|
70
|
+
result = sb.run(["python3", "-c", "import numpy; print(numpy.__version__)"])
|
|
71
|
+
print(result.output)
|
|
72
|
+
# sandbox auto-removed
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Streaming
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
for event in client.run_stream(["python3", "script.py"]):
|
|
79
|
+
if event.type == "output":
|
|
80
|
+
print(event.data["data"], end="")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
client = AgentKernel(
|
|
87
|
+
base_url="http://localhost:18888", # default
|
|
88
|
+
api_key="sk-...", # optional
|
|
89
|
+
timeout=30.0, # default
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or use environment variables:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
export AGENTKERNEL_BASE_URL=http://localhost:18888
|
|
97
|
+
export AGENTKERNEL_API_KEY=sk-...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# agentkernel
|
|
2
|
+
|
|
3
|
+
Python SDK for [agentkernel](https://github.com/thrashr888/agentkernel) — run AI coding agents in secure, isolated microVMs.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agentkernel-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.10+.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from agentkernel import AgentKernel
|
|
17
|
+
|
|
18
|
+
with AgentKernel() as client:
|
|
19
|
+
result = client.run(["echo", "hello"])
|
|
20
|
+
print(result.output) # "hello\n"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Async
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from agentkernel import AsyncAgentKernel
|
|
27
|
+
|
|
28
|
+
async with AsyncAgentKernel() as client:
|
|
29
|
+
result = await client.run(["echo", "hello"])
|
|
30
|
+
print(result.output)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Sandbox Sessions
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
with AgentKernel() as client:
|
|
37
|
+
with client.sandbox("test", image="python:3.12-alpine") as sb:
|
|
38
|
+
sb.run(["pip", "install", "numpy"])
|
|
39
|
+
result = sb.run(["python3", "-c", "import numpy; print(numpy.__version__)"])
|
|
40
|
+
print(result.output)
|
|
41
|
+
# sandbox auto-removed
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Streaming
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
for event in client.run_stream(["python3", "script.py"]):
|
|
48
|
+
if event.type == "output":
|
|
49
|
+
print(event.data["data"], end="")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
client = AgentKernel(
|
|
56
|
+
base_url="http://localhost:18888", # default
|
|
57
|
+
api_key="sk-...", # optional
|
|
58
|
+
timeout=30.0, # default
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or use environment variables:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export AGENTKERNEL_BASE_URL=http://localhost:18888
|
|
66
|
+
export AGENTKERNEL_API_KEY=sk-...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Async example for agentkernel Python SDK."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from agentkernel import AsyncAgentKernel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def main() -> None:
|
|
10
|
+
async with AsyncAgentKernel() as client:
|
|
11
|
+
# Health check
|
|
12
|
+
print("Health:", await client.health())
|
|
13
|
+
|
|
14
|
+
# Run a command
|
|
15
|
+
result = await client.run(["echo", "Hello from async agentkernel!"])
|
|
16
|
+
print("Output:", result.output)
|
|
17
|
+
|
|
18
|
+
# Sandbox session with async context manager
|
|
19
|
+
async with client.sandbox("async-demo", image="python:3.12-alpine") as sb:
|
|
20
|
+
await sb.run(["pip", "install", "requests"])
|
|
21
|
+
result = await sb.run(["python3", "-c", "import requests; print(requests.__version__)"])
|
|
22
|
+
print(result.output)
|
|
23
|
+
# sandbox auto-removed
|
|
24
|
+
|
|
25
|
+
# Async streaming
|
|
26
|
+
async for event in client.run_stream(["echo", "streaming async"]):
|
|
27
|
+
if event.event_type == "output":
|
|
28
|
+
sys.stdout.write(event.data.get("data", ""))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Quick start example for agentkernel Python SDK."""
|
|
2
|
+
|
|
3
|
+
from agentkernel import AgentKernel
|
|
4
|
+
|
|
5
|
+
client = AgentKernel()
|
|
6
|
+
|
|
7
|
+
# Health check
|
|
8
|
+
print("Health:", client.health())
|
|
9
|
+
|
|
10
|
+
# Run a command
|
|
11
|
+
result = client.run(["echo", "Hello from agentkernel!"])
|
|
12
|
+
print("Output:", result.output)
|
|
13
|
+
|
|
14
|
+
# List sandboxes
|
|
15
|
+
sandboxes = client.list_sandboxes()
|
|
16
|
+
print("Sandboxes:", sandboxes)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Sandbox session example for agentkernel Python SDK."""
|
|
2
|
+
|
|
3
|
+
from agentkernel import AgentKernel
|
|
4
|
+
|
|
5
|
+
client = AgentKernel()
|
|
6
|
+
|
|
7
|
+
# Create a sandbox session — auto-removed when context manager exits
|
|
8
|
+
with client.sandbox("demo", image="python:3.12-alpine") as sb:
|
|
9
|
+
# Install a package
|
|
10
|
+
sb.run(["pip", "install", "numpy"])
|
|
11
|
+
|
|
12
|
+
# Run code
|
|
13
|
+
result = sb.run(["python3", "-c", "import numpy; print(f'numpy {numpy.__version__}')"])
|
|
14
|
+
print(result.output)
|
|
15
|
+
|
|
16
|
+
# Sandbox is automatically removed here
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Streaming example for agentkernel Python SDK."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from agentkernel import AgentKernel
|
|
6
|
+
|
|
7
|
+
client = AgentKernel()
|
|
8
|
+
|
|
9
|
+
# Stream output from a command
|
|
10
|
+
for event in client.run_stream(["python3", "-c", "print('Hello from streaming!')"]):
|
|
11
|
+
match event.event_type:
|
|
12
|
+
case "started":
|
|
13
|
+
print("[started]", event.data)
|
|
14
|
+
case "output":
|
|
15
|
+
sys.stdout.write(event.data.get("data", ""))
|
|
16
|
+
case "done":
|
|
17
|
+
print(f"\n[done] exit_code: {event.data.get('exit_code')}")
|
|
18
|
+
case "error":
|
|
19
|
+
print(f"[error] {event.data.get('message')}", file=sys.stderr)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentkernel-sdk"
|
|
7
|
+
version = "0.7.1"
|
|
8
|
+
description = "Python SDK for agentkernel — run AI coding agents in secure, isolated microVMs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Paul Thrasher", email = "thrashr888@gmail.com" }]
|
|
13
|
+
keywords = ["agentkernel", "sandbox", "microvm", "ai", "agent", "firecracker"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["httpx>=0.27", "httpx-sse>=0.4", "pydantic>=2.0"]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"pytest-asyncio>=0.24",
|
|
32
|
+
"pytest-httpx>=0.34",
|
|
33
|
+
"mypy>=1.13",
|
|
34
|
+
"ruff>=0.8",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/thrashr888/agentkernel"
|
|
39
|
+
Repository = "https://github.com/thrashr888/agentkernel/tree/main/sdk/python"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/agentkernel"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
line-length = 100
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint]
|
|
49
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
python_version = "3.10"
|
|
53
|
+
strict = true
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
markers = ["integration: requires running agentkernel server"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""agentkernel SDK — run AI coding agents in secure, isolated microVMs."""
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncAgentKernel, AsyncSandboxSession
|
|
4
|
+
from .client import AgentKernel, SandboxSession
|
|
5
|
+
from .errors import (
|
|
6
|
+
AgentKernelError,
|
|
7
|
+
AuthError,
|
|
8
|
+
NetworkError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
ServerError,
|
|
11
|
+
StreamError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from .types import (
|
|
15
|
+
BatchCommand,
|
|
16
|
+
BatchResult,
|
|
17
|
+
BatchRunResponse,
|
|
18
|
+
CreateSandboxOptions,
|
|
19
|
+
FileReadResponse,
|
|
20
|
+
RunOptions,
|
|
21
|
+
RunOutput,
|
|
22
|
+
SandboxInfo,
|
|
23
|
+
SecurityProfile,
|
|
24
|
+
StreamEvent,
|
|
25
|
+
StreamEventType,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AgentKernel",
|
|
30
|
+
"AsyncAgentKernel",
|
|
31
|
+
"SandboxSession",
|
|
32
|
+
"AsyncSandboxSession",
|
|
33
|
+
"AgentKernelError",
|
|
34
|
+
"AuthError",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"ServerError",
|
|
38
|
+
"NetworkError",
|
|
39
|
+
"StreamError",
|
|
40
|
+
"BatchCommand",
|
|
41
|
+
"BatchResult",
|
|
42
|
+
"BatchRunResponse",
|
|
43
|
+
"CreateSandboxOptions",
|
|
44
|
+
"FileReadResponse",
|
|
45
|
+
"RunOptions",
|
|
46
|
+
"RunOutput",
|
|
47
|
+
"SandboxInfo",
|
|
48
|
+
"SecurityProfile",
|
|
49
|
+
"StreamEvent",
|
|
50
|
+
"StreamEventType",
|
|
51
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Configuration resolution for the agentkernel SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
DEFAULT_BASE_URL = "http://localhost:18888"
|
|
9
|
+
DEFAULT_TIMEOUT = 30.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Config:
|
|
14
|
+
base_url: str
|
|
15
|
+
api_key: str | None
|
|
16
|
+
timeout: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_config(
|
|
20
|
+
base_url: str | None = None,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
timeout: float | None = None,
|
|
23
|
+
) -> Config:
|
|
24
|
+
"""Resolve config from constructor args > env vars > defaults."""
|
|
25
|
+
return Config(
|
|
26
|
+
base_url=(base_url or os.environ.get("AGENTKERNEL_BASE_URL") or DEFAULT_BASE_URL).rstrip("/"),
|
|
27
|
+
api_key=api_key or os.environ.get("AGENTKERNEL_API_KEY"),
|
|
28
|
+
timeout=timeout if timeout is not None else DEFAULT_TIMEOUT,
|
|
29
|
+
)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Asynchronous client for the agentkernel HTTP API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._config import resolve_config
|
|
12
|
+
from .errors import AgentKernelError, NetworkError, error_from_status
|
|
13
|
+
from .types import (
|
|
14
|
+
BatchRunResponse,
|
|
15
|
+
CreateSandboxOptions,
|
|
16
|
+
FileReadResponse,
|
|
17
|
+
RunOptions,
|
|
18
|
+
RunOutput,
|
|
19
|
+
SandboxInfo,
|
|
20
|
+
SecurityProfile,
|
|
21
|
+
StreamEvent,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SDK_VERSION = "0.4.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncSandboxSession:
|
|
28
|
+
"""An async sandbox session with auto-cleanup on context manager exit."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, name: str, client: AsyncAgentKernel) -> None:
|
|
31
|
+
self.name = name
|
|
32
|
+
self._client = client
|
|
33
|
+
self._removed = False
|
|
34
|
+
|
|
35
|
+
async def run(self, command: list[str]) -> RunOutput:
|
|
36
|
+
"""Run a command in this sandbox."""
|
|
37
|
+
return await self._client.exec_in_sandbox(self.name, command)
|
|
38
|
+
|
|
39
|
+
async def info(self) -> SandboxInfo:
|
|
40
|
+
"""Get sandbox info."""
|
|
41
|
+
return await self._client.get_sandbox(self.name)
|
|
42
|
+
|
|
43
|
+
async def remove(self) -> None:
|
|
44
|
+
"""Remove the sandbox. Idempotent."""
|
|
45
|
+
if self._removed:
|
|
46
|
+
return
|
|
47
|
+
self._removed = True
|
|
48
|
+
await self._client.remove_sandbox(self.name)
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self) -> AsyncSandboxSession:
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
async def __aexit__(
|
|
54
|
+
self,
|
|
55
|
+
exc_type: type[BaseException] | None,
|
|
56
|
+
exc_val: BaseException | None,
|
|
57
|
+
exc_tb: TracebackType | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
await self.remove()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AsyncAgentKernel:
|
|
63
|
+
"""Asynchronous client for the agentkernel HTTP API.
|
|
64
|
+
|
|
65
|
+
Example::
|
|
66
|
+
|
|
67
|
+
async with AsyncAgentKernel() as client:
|
|
68
|
+
result = await client.run(["echo", "hello"])
|
|
69
|
+
print(result.output)
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
base_url: str | None = None,
|
|
75
|
+
api_key: str | None = None,
|
|
76
|
+
timeout: float | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
config = resolve_config(base_url, api_key, timeout)
|
|
79
|
+
headers: dict[str, str] = {"User-Agent": f"agentkernel-python-sdk/{SDK_VERSION}"}
|
|
80
|
+
if config.api_key:
|
|
81
|
+
headers["Authorization"] = f"Bearer {config.api_key}"
|
|
82
|
+
self._http = httpx.AsyncClient(
|
|
83
|
+
base_url=config.base_url,
|
|
84
|
+
headers=headers,
|
|
85
|
+
timeout=config.timeout,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def close(self) -> None:
|
|
89
|
+
"""Close the HTTP client."""
|
|
90
|
+
await self._http.aclose()
|
|
91
|
+
|
|
92
|
+
async def __aenter__(self) -> AsyncAgentKernel:
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(
|
|
96
|
+
self,
|
|
97
|
+
exc_type: type[BaseException] | None,
|
|
98
|
+
exc_val: BaseException | None,
|
|
99
|
+
exc_tb: TracebackType | None,
|
|
100
|
+
) -> None:
|
|
101
|
+
await self.close()
|
|
102
|
+
|
|
103
|
+
# -- API methods --
|
|
104
|
+
|
|
105
|
+
async def health(self) -> str:
|
|
106
|
+
"""Health check. Returns 'ok'."""
|
|
107
|
+
return await self._request("GET", "/health")
|
|
108
|
+
|
|
109
|
+
async def run(
|
|
110
|
+
self,
|
|
111
|
+
command: list[str],
|
|
112
|
+
*,
|
|
113
|
+
image: str | None = None,
|
|
114
|
+
profile: SecurityProfile | None = None,
|
|
115
|
+
fast: bool = True,
|
|
116
|
+
) -> RunOutput:
|
|
117
|
+
"""Run a command in a temporary sandbox."""
|
|
118
|
+
data = await self._request(
|
|
119
|
+
"POST",
|
|
120
|
+
"/run",
|
|
121
|
+
json={"command": command, "image": image, "profile": profile, "fast": fast},
|
|
122
|
+
)
|
|
123
|
+
return RunOutput(**data)
|
|
124
|
+
|
|
125
|
+
async def run_stream(
|
|
126
|
+
self,
|
|
127
|
+
command: list[str],
|
|
128
|
+
*,
|
|
129
|
+
image: str | None = None,
|
|
130
|
+
profile: SecurityProfile | None = None,
|
|
131
|
+
fast: bool = True,
|
|
132
|
+
) -> AsyncIterator[StreamEvent]:
|
|
133
|
+
"""Run a command with SSE streaming output."""
|
|
134
|
+
from .sse import iter_sse_async
|
|
135
|
+
|
|
136
|
+
response = await self._http.send(
|
|
137
|
+
self._http.build_request(
|
|
138
|
+
"POST",
|
|
139
|
+
"/run/stream",
|
|
140
|
+
json={"command": command, "image": image, "profile": profile, "fast": fast},
|
|
141
|
+
),
|
|
142
|
+
stream=True,
|
|
143
|
+
)
|
|
144
|
+
if response.status_code >= 400:
|
|
145
|
+
await response.aread()
|
|
146
|
+
raise error_from_status(response.status_code, response.text)
|
|
147
|
+
return iter_sse_async(response)
|
|
148
|
+
|
|
149
|
+
async def list_sandboxes(self) -> list[SandboxInfo]:
|
|
150
|
+
"""List all sandboxes."""
|
|
151
|
+
data = await self._request("GET", "/sandboxes")
|
|
152
|
+
return [SandboxInfo(**s) for s in data]
|
|
153
|
+
|
|
154
|
+
async def create_sandbox(
|
|
155
|
+
self,
|
|
156
|
+
name: str,
|
|
157
|
+
*,
|
|
158
|
+
image: str | None = None,
|
|
159
|
+
vcpus: int | None = None,
|
|
160
|
+
memory_mb: int | None = None,
|
|
161
|
+
profile: SecurityProfile | None = None,
|
|
162
|
+
) -> SandboxInfo:
|
|
163
|
+
"""Create a new sandbox."""
|
|
164
|
+
data = await self._request(
|
|
165
|
+
"POST",
|
|
166
|
+
"/sandboxes",
|
|
167
|
+
json={"name": name, "image": image, "vcpus": vcpus, "memory_mb": memory_mb, "profile": profile},
|
|
168
|
+
)
|
|
169
|
+
return SandboxInfo(**data)
|
|
170
|
+
|
|
171
|
+
async def get_sandbox(self, name: str) -> SandboxInfo:
|
|
172
|
+
"""Get info about a sandbox."""
|
|
173
|
+
data = await self._request("GET", f"/sandboxes/{name}")
|
|
174
|
+
return SandboxInfo(**data)
|
|
175
|
+
|
|
176
|
+
async def remove_sandbox(self, name: str) -> None:
|
|
177
|
+
"""Remove a sandbox."""
|
|
178
|
+
await self._request("DELETE", f"/sandboxes/{name}")
|
|
179
|
+
|
|
180
|
+
async def exec_in_sandbox(self, name: str, command: list[str]) -> RunOutput:
|
|
181
|
+
"""Run a command in an existing sandbox."""
|
|
182
|
+
data = await self._request("POST", f"/sandboxes/{name}/exec", json={"command": command})
|
|
183
|
+
return RunOutput(**data)
|
|
184
|
+
|
|
185
|
+
async def read_file(self, name: str, path: str) -> FileReadResponse:
|
|
186
|
+
"""Read a file from a sandbox."""
|
|
187
|
+
data = await self._request("GET", f"/sandboxes/{name}/files/{path}")
|
|
188
|
+
return FileReadResponse(**data)
|
|
189
|
+
|
|
190
|
+
async def write_file(
|
|
191
|
+
self,
|
|
192
|
+
name: str,
|
|
193
|
+
path: str,
|
|
194
|
+
content: str,
|
|
195
|
+
*,
|
|
196
|
+
encoding: str = "utf8",
|
|
197
|
+
) -> str:
|
|
198
|
+
"""Write a file to a sandbox."""
|
|
199
|
+
return await self._request(
|
|
200
|
+
"PUT",
|
|
201
|
+
f"/sandboxes/{name}/files/{path}",
|
|
202
|
+
json={"content": content, "encoding": encoding},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
async def delete_file(self, name: str, path: str) -> str:
|
|
206
|
+
"""Delete a file from a sandbox."""
|
|
207
|
+
return await self._request("DELETE", f"/sandboxes/{name}/files/{path}")
|
|
208
|
+
|
|
209
|
+
async def get_sandbox_logs(self, name: str) -> list[dict]:
|
|
210
|
+
"""Get audit log entries for a sandbox."""
|
|
211
|
+
return await self._request("GET", f"/sandboxes/{name}/logs")
|
|
212
|
+
|
|
213
|
+
async def batch_run(self, commands: list[list[str]]) -> BatchRunResponse:
|
|
214
|
+
"""Run multiple commands in parallel."""
|
|
215
|
+
batch_commands = [{"command": cmd} for cmd in commands]
|
|
216
|
+
data = await self._request("POST", "/batch/run", json={"commands": batch_commands})
|
|
217
|
+
return BatchRunResponse(**data)
|
|
218
|
+
|
|
219
|
+
async def sandbox(
|
|
220
|
+
self,
|
|
221
|
+
name: str,
|
|
222
|
+
*,
|
|
223
|
+
image: str | None = None,
|
|
224
|
+
vcpus: int | None = None,
|
|
225
|
+
memory_mb: int | None = None,
|
|
226
|
+
profile: SecurityProfile | None = None,
|
|
227
|
+
) -> AsyncSandboxSession:
|
|
228
|
+
"""Create a sandbox session with automatic cleanup.
|
|
229
|
+
|
|
230
|
+
Example::
|
|
231
|
+
|
|
232
|
+
async with await client.sandbox("test") as sb:
|
|
233
|
+
await sb.run(["echo", "hello"])
|
|
234
|
+
# sandbox auto-removed
|
|
235
|
+
"""
|
|
236
|
+
await self.create_sandbox(name, image=image, vcpus=vcpus, memory_mb=memory_mb, profile=profile)
|
|
237
|
+
return AsyncSandboxSession(name, self)
|
|
238
|
+
|
|
239
|
+
# -- Internal --
|
|
240
|
+
|
|
241
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
242
|
+
try:
|
|
243
|
+
response = await self._http.request(method, path, **kwargs)
|
|
244
|
+
except httpx.ConnectError as e:
|
|
245
|
+
raise NetworkError(f"Failed to connect: {e}") from e
|
|
246
|
+
except httpx.TimeoutException as e:
|
|
247
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
248
|
+
|
|
249
|
+
if response.status_code >= 400:
|
|
250
|
+
raise error_from_status(response.status_code, response.text)
|
|
251
|
+
|
|
252
|
+
data = response.json()
|
|
253
|
+
if not data.get("success"):
|
|
254
|
+
raise AgentKernelError(data.get("error", "Unknown error"))
|
|
255
|
+
return data.get("data")
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Synchronous client for the agentkernel HTTP API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._config import resolve_config
|
|
11
|
+
from .errors import AgentKernelError, NetworkError, error_from_status
|
|
12
|
+
from .types import (
|
|
13
|
+
BatchRunResponse,
|
|
14
|
+
CreateSandboxOptions,
|
|
15
|
+
FileReadResponse,
|
|
16
|
+
RunOptions,
|
|
17
|
+
RunOutput,
|
|
18
|
+
SandboxInfo,
|
|
19
|
+
SecurityProfile,
|
|
20
|
+
StreamEvent,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
SDK_VERSION = "0.4.0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SandboxSession:
|
|
27
|
+
"""A sandbox session with auto-cleanup on context manager exit."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, name: str, client: AgentKernel) -> None:
|
|
30
|
+
self.name = name
|
|
31
|
+
self._client = client
|
|
32
|
+
self._removed = False
|
|
33
|
+
|
|
34
|
+
def run(self, command: list[str]) -> RunOutput:
|
|
35
|
+
"""Run a command in this sandbox."""
|
|
36
|
+
return self._client.exec_in_sandbox(self.name, command)
|
|
37
|
+
|
|
38
|
+
def info(self) -> SandboxInfo:
|
|
39
|
+
"""Get sandbox info."""
|
|
40
|
+
return self._client.get_sandbox(self.name)
|
|
41
|
+
|
|
42
|
+
def remove(self) -> None:
|
|
43
|
+
"""Remove the sandbox. Idempotent."""
|
|
44
|
+
if self._removed:
|
|
45
|
+
return
|
|
46
|
+
self._removed = True
|
|
47
|
+
self._client.remove_sandbox(self.name)
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> SandboxSession:
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *args: Any) -> None:
|
|
53
|
+
self.remove()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AgentKernel:
|
|
57
|
+
"""Synchronous client for the agentkernel HTTP API.
|
|
58
|
+
|
|
59
|
+
Example::
|
|
60
|
+
|
|
61
|
+
with AgentKernel() as client:
|
|
62
|
+
result = client.run(["echo", "hello"])
|
|
63
|
+
print(result.output)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
base_url: str | None = None,
|
|
69
|
+
api_key: str | None = None,
|
|
70
|
+
timeout: float | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
config = resolve_config(base_url, api_key, timeout)
|
|
73
|
+
headers: dict[str, str] = {"User-Agent": f"agentkernel-python-sdk/{SDK_VERSION}"}
|
|
74
|
+
if config.api_key:
|
|
75
|
+
headers["Authorization"] = f"Bearer {config.api_key}"
|
|
76
|
+
self._http = httpx.Client(
|
|
77
|
+
base_url=config.base_url,
|
|
78
|
+
headers=headers,
|
|
79
|
+
timeout=config.timeout,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
"""Close the HTTP client."""
|
|
84
|
+
self._http.close()
|
|
85
|
+
|
|
86
|
+
def __enter__(self) -> AgentKernel:
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *args: Any) -> None:
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
# -- API methods --
|
|
93
|
+
|
|
94
|
+
def health(self) -> str:
|
|
95
|
+
"""Health check. Returns 'ok'."""
|
|
96
|
+
return self._request("GET", "/health")
|
|
97
|
+
|
|
98
|
+
def run(
|
|
99
|
+
self,
|
|
100
|
+
command: list[str],
|
|
101
|
+
*,
|
|
102
|
+
image: str | None = None,
|
|
103
|
+
profile: SecurityProfile | None = None,
|
|
104
|
+
fast: bool = True,
|
|
105
|
+
) -> RunOutput:
|
|
106
|
+
"""Run a command in a temporary sandbox."""
|
|
107
|
+
data = self._request(
|
|
108
|
+
"POST",
|
|
109
|
+
"/run",
|
|
110
|
+
json={"command": command, "image": image, "profile": profile, "fast": fast},
|
|
111
|
+
)
|
|
112
|
+
return RunOutput(**data)
|
|
113
|
+
|
|
114
|
+
def run_stream(
|
|
115
|
+
self,
|
|
116
|
+
command: list[str],
|
|
117
|
+
*,
|
|
118
|
+
image: str | None = None,
|
|
119
|
+
profile: SecurityProfile | None = None,
|
|
120
|
+
fast: bool = True,
|
|
121
|
+
) -> Iterator[StreamEvent]:
|
|
122
|
+
"""Run a command with SSE streaming output."""
|
|
123
|
+
from .sse import iter_sse_sync
|
|
124
|
+
|
|
125
|
+
with self._http.stream(
|
|
126
|
+
"POST",
|
|
127
|
+
"/run/stream",
|
|
128
|
+
json={"command": command, "image": image, "profile": profile, "fast": fast},
|
|
129
|
+
) as response:
|
|
130
|
+
if response.status_code >= 400:
|
|
131
|
+
response.read()
|
|
132
|
+
raise error_from_status(response.status_code, response.text)
|
|
133
|
+
yield from iter_sse_sync(response)
|
|
134
|
+
|
|
135
|
+
def list_sandboxes(self) -> list[SandboxInfo]:
|
|
136
|
+
"""List all sandboxes."""
|
|
137
|
+
data = self._request("GET", "/sandboxes")
|
|
138
|
+
return [SandboxInfo(**s) for s in data]
|
|
139
|
+
|
|
140
|
+
def create_sandbox(
|
|
141
|
+
self,
|
|
142
|
+
name: str,
|
|
143
|
+
*,
|
|
144
|
+
image: str | None = None,
|
|
145
|
+
vcpus: int | None = None,
|
|
146
|
+
memory_mb: int | None = None,
|
|
147
|
+
profile: SecurityProfile | None = None,
|
|
148
|
+
) -> SandboxInfo:
|
|
149
|
+
"""Create a new sandbox."""
|
|
150
|
+
data = self._request(
|
|
151
|
+
"POST",
|
|
152
|
+
"/sandboxes",
|
|
153
|
+
json={"name": name, "image": image, "vcpus": vcpus, "memory_mb": memory_mb, "profile": profile},
|
|
154
|
+
)
|
|
155
|
+
return SandboxInfo(**data)
|
|
156
|
+
|
|
157
|
+
def get_sandbox(self, name: str) -> SandboxInfo:
|
|
158
|
+
"""Get info about a sandbox."""
|
|
159
|
+
data = self._request("GET", f"/sandboxes/{name}")
|
|
160
|
+
return SandboxInfo(**data)
|
|
161
|
+
|
|
162
|
+
def remove_sandbox(self, name: str) -> None:
|
|
163
|
+
"""Remove a sandbox."""
|
|
164
|
+
self._request("DELETE", f"/sandboxes/{name}")
|
|
165
|
+
|
|
166
|
+
def exec_in_sandbox(self, name: str, command: list[str]) -> RunOutput:
|
|
167
|
+
"""Run a command in an existing sandbox."""
|
|
168
|
+
data = self._request("POST", f"/sandboxes/{name}/exec", json={"command": command})
|
|
169
|
+
return RunOutput(**data)
|
|
170
|
+
|
|
171
|
+
def read_file(self, name: str, path: str) -> FileReadResponse:
|
|
172
|
+
"""Read a file from a sandbox."""
|
|
173
|
+
data = self._request("GET", f"/sandboxes/{name}/files/{path}")
|
|
174
|
+
return FileReadResponse(**data)
|
|
175
|
+
|
|
176
|
+
def write_file(
|
|
177
|
+
self,
|
|
178
|
+
name: str,
|
|
179
|
+
path: str,
|
|
180
|
+
content: str,
|
|
181
|
+
*,
|
|
182
|
+
encoding: str = "utf8",
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Write a file to a sandbox."""
|
|
185
|
+
return self._request(
|
|
186
|
+
"PUT",
|
|
187
|
+
f"/sandboxes/{name}/files/{path}",
|
|
188
|
+
json={"content": content, "encoding": encoding},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def delete_file(self, name: str, path: str) -> str:
|
|
192
|
+
"""Delete a file from a sandbox."""
|
|
193
|
+
return self._request("DELETE", f"/sandboxes/{name}/files/{path}")
|
|
194
|
+
|
|
195
|
+
def get_sandbox_logs(self, name: str) -> list[dict]:
|
|
196
|
+
"""Get audit log entries for a sandbox."""
|
|
197
|
+
return self._request("GET", f"/sandboxes/{name}/logs")
|
|
198
|
+
|
|
199
|
+
def batch_run(self, commands: list[list[str]]) -> BatchRunResponse:
|
|
200
|
+
"""Run multiple commands in parallel."""
|
|
201
|
+
batch_commands = [{"command": cmd} for cmd in commands]
|
|
202
|
+
data = self._request("POST", "/batch/run", json={"commands": batch_commands})
|
|
203
|
+
return BatchRunResponse(**data)
|
|
204
|
+
|
|
205
|
+
def sandbox(
|
|
206
|
+
self,
|
|
207
|
+
name: str,
|
|
208
|
+
*,
|
|
209
|
+
image: str | None = None,
|
|
210
|
+
vcpus: int | None = None,
|
|
211
|
+
memory_mb: int | None = None,
|
|
212
|
+
profile: SecurityProfile | None = None,
|
|
213
|
+
) -> SandboxSession:
|
|
214
|
+
"""Create a sandbox session with automatic cleanup.
|
|
215
|
+
|
|
216
|
+
Example::
|
|
217
|
+
|
|
218
|
+
with client.sandbox("test", image="python:3.12-alpine") as sb:
|
|
219
|
+
sb.run(["pip", "install", "numpy"])
|
|
220
|
+
# sandbox auto-removed
|
|
221
|
+
"""
|
|
222
|
+
self.create_sandbox(name, image=image, vcpus=vcpus, memory_mb=memory_mb, profile=profile)
|
|
223
|
+
return SandboxSession(name, self)
|
|
224
|
+
|
|
225
|
+
# -- Internal --
|
|
226
|
+
|
|
227
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
228
|
+
try:
|
|
229
|
+
response = self._http.request(method, path, **kwargs)
|
|
230
|
+
except httpx.ConnectError as e:
|
|
231
|
+
raise NetworkError(f"Failed to connect: {e}") from e
|
|
232
|
+
except httpx.TimeoutException as e:
|
|
233
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
234
|
+
|
|
235
|
+
if response.status_code >= 400:
|
|
236
|
+
raise error_from_status(response.status_code, response.text)
|
|
237
|
+
|
|
238
|
+
data = response.json()
|
|
239
|
+
if not data.get("success"):
|
|
240
|
+
raise AgentKernelError(data.get("error", "Unknown error"))
|
|
241
|
+
return data.get("data")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Exception hierarchy for the agentkernel SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentKernelError(Exception):
|
|
7
|
+
"""Base error for all agentkernel SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthError(AgentKernelError):
|
|
11
|
+
"""401 Unauthorized."""
|
|
12
|
+
|
|
13
|
+
status = 401
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotFoundError(AgentKernelError):
|
|
17
|
+
"""404 Not Found."""
|
|
18
|
+
|
|
19
|
+
status = 404
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValidationError(AgentKernelError):
|
|
23
|
+
"""400 Bad Request."""
|
|
24
|
+
|
|
25
|
+
status = 400
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ServerError(AgentKernelError):
|
|
29
|
+
"""500 Internal Server Error."""
|
|
30
|
+
|
|
31
|
+
status = 500
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NetworkError(AgentKernelError):
|
|
35
|
+
"""Network / connection error."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StreamError(AgentKernelError):
|
|
39
|
+
"""SSE streaming error."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def error_from_status(status: int, body: str) -> AgentKernelError:
|
|
43
|
+
"""Map an HTTP status code + body to the appropriate error."""
|
|
44
|
+
import json
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
parsed = json.loads(body)
|
|
48
|
+
message = parsed.get("error", body)
|
|
49
|
+
except (json.JSONDecodeError, TypeError):
|
|
50
|
+
message = body
|
|
51
|
+
|
|
52
|
+
errors = {400: ValidationError, 401: AuthError, 404: NotFoundError}
|
|
53
|
+
cls = errors.get(status, ServerError)
|
|
54
|
+
return cls(message)
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""SSE stream parsing for the agentkernel SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import httpx_sse
|
|
11
|
+
|
|
12
|
+
from .types import StreamEvent
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
KNOWN_EVENTS = frozenset({"started", "progress", "output", "done", "error"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def iter_sse_sync(response: httpx.Response) -> Iterator[StreamEvent]:
|
|
21
|
+
"""Parse SSE events from a sync httpx response."""
|
|
22
|
+
with httpx_sse.connect_sse(response.stream) as event_source: # type: ignore[arg-type]
|
|
23
|
+
for sse in event_source.iter_sse():
|
|
24
|
+
if sse.event not in KNOWN_EVENTS:
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(sse.data)
|
|
28
|
+
except (json.JSONDecodeError, TypeError):
|
|
29
|
+
data = {"raw": sse.data}
|
|
30
|
+
event = StreamEvent(type=sse.event, data=data) # type: ignore[arg-type]
|
|
31
|
+
yield event
|
|
32
|
+
if event.type in ("done", "error"):
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def iter_sse_async(response: httpx.Response) -> AsyncIterator[StreamEvent]:
|
|
37
|
+
"""Parse SSE events from an async httpx response."""
|
|
38
|
+
async with httpx_sse.aconnect_sse(response.stream) as event_source: # type: ignore[arg-type]
|
|
39
|
+
async for sse in event_source.aiter_sse():
|
|
40
|
+
if sse.event not in KNOWN_EVENTS:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(sse.data)
|
|
44
|
+
except (json.JSONDecodeError, TypeError):
|
|
45
|
+
data = {"raw": sse.data}
|
|
46
|
+
event = StreamEvent(type=sse.event, data=data) # type: ignore[arg-type]
|
|
47
|
+
yield event
|
|
48
|
+
if event.type in ("done", "error"):
|
|
49
|
+
return
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Type definitions for the agentkernel SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SecurityProfile = Literal["permissive", "moderate", "restrictive"]
|
|
11
|
+
SandboxStatus = Literal["running", "stopped"]
|
|
12
|
+
StreamEventType = Literal["started", "progress", "output", "done", "error"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RunOutput(BaseModel):
|
|
16
|
+
"""Output from a command execution."""
|
|
17
|
+
|
|
18
|
+
output: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SandboxInfo(BaseModel):
|
|
22
|
+
"""Information about a sandbox."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
status: SandboxStatus
|
|
26
|
+
backend: str
|
|
27
|
+
image: str | None = None
|
|
28
|
+
vcpus: int | None = None
|
|
29
|
+
memory_mb: int | None = None
|
|
30
|
+
created_at: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RunOptions(BaseModel):
|
|
34
|
+
"""Options for the run command."""
|
|
35
|
+
|
|
36
|
+
image: str | None = None
|
|
37
|
+
profile: SecurityProfile | None = None
|
|
38
|
+
fast: bool = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CreateSandboxOptions(BaseModel):
|
|
42
|
+
"""Options for creating a sandbox."""
|
|
43
|
+
|
|
44
|
+
image: str | None = None
|
|
45
|
+
vcpus: int | None = None
|
|
46
|
+
memory_mb: int | None = None
|
|
47
|
+
profile: SecurityProfile | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StreamEvent(BaseModel):
|
|
51
|
+
"""SSE stream event."""
|
|
52
|
+
|
|
53
|
+
type: StreamEventType
|
|
54
|
+
data: dict
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FileReadResponse(BaseModel):
|
|
58
|
+
"""Response from reading a file."""
|
|
59
|
+
|
|
60
|
+
content: str
|
|
61
|
+
encoding: str
|
|
62
|
+
size: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BatchCommand(BaseModel):
|
|
66
|
+
"""A command for batch execution."""
|
|
67
|
+
|
|
68
|
+
command: list[str]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BatchResult(BaseModel):
|
|
72
|
+
"""Result of a single batch command."""
|
|
73
|
+
|
|
74
|
+
output: str | None = None
|
|
75
|
+
error: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BatchRunResponse(BaseModel):
|
|
79
|
+
"""Response from batch execution."""
|
|
80
|
+
|
|
81
|
+
results: list[BatchResult]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Tests for the asynchronous AsyncAgentKernel client."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pytest_httpx import HTTPXMock
|
|
5
|
+
|
|
6
|
+
from agentkernel import AsyncAgentKernel, NotFoundError, RunOutput, SandboxInfo
|
|
7
|
+
|
|
8
|
+
BASE_URL = "http://localhost:9999"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_client(**kwargs) -> AsyncAgentKernel:
|
|
12
|
+
return AsyncAgentKernel(base_url=BASE_URL, **kwargs)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestAsyncHealth:
|
|
16
|
+
async def test_returns_ok(self, httpx_mock: HTTPXMock) -> None:
|
|
17
|
+
httpx_mock.add_response(json={"success": True, "data": "ok"})
|
|
18
|
+
async with make_client() as client:
|
|
19
|
+
assert await client.health() == "ok"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestAsyncRun:
|
|
23
|
+
async def test_returns_output(self, httpx_mock: HTTPXMock) -> None:
|
|
24
|
+
httpx_mock.add_response(json={"success": True, "data": {"output": "hello\n"}})
|
|
25
|
+
async with make_client() as client:
|
|
26
|
+
result = await client.run(["echo", "hello"])
|
|
27
|
+
assert isinstance(result, RunOutput)
|
|
28
|
+
assert result.output == "hello\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestAsyncListSandboxes:
|
|
32
|
+
async def test_returns_list(self, httpx_mock: HTTPXMock) -> None:
|
|
33
|
+
httpx_mock.add_response(
|
|
34
|
+
json={
|
|
35
|
+
"success": True,
|
|
36
|
+
"data": [{"name": "sb-1", "status": "running", "backend": "docker"}],
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
async with make_client() as client:
|
|
40
|
+
result = await client.list_sandboxes()
|
|
41
|
+
assert len(result) == 1
|
|
42
|
+
assert isinstance(result[0], SandboxInfo)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestAsyncGetSandbox:
|
|
46
|
+
async def test_not_found(self, httpx_mock: HTTPXMock) -> None:
|
|
47
|
+
httpx_mock.add_response(status_code=404, json={"success": False, "error": "Not found"})
|
|
48
|
+
async with make_client() as client:
|
|
49
|
+
with pytest.raises(NotFoundError):
|
|
50
|
+
await client.get_sandbox("missing")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestAsyncSandboxSession:
|
|
54
|
+
async def test_auto_removes(self, httpx_mock: HTTPXMock) -> None:
|
|
55
|
+
httpx_mock.add_response(
|
|
56
|
+
json={"success": True, "data": {"name": "sess", "status": "running", "backend": "docker"}}
|
|
57
|
+
)
|
|
58
|
+
httpx_mock.add_response(json={"success": True, "data": "Sandbox removed"})
|
|
59
|
+
|
|
60
|
+
async with make_client() as client:
|
|
61
|
+
async with await client.sandbox("sess") as sb:
|
|
62
|
+
assert sb.name == "sess"
|
|
63
|
+
requests = httpx_mock.get_requests()
|
|
64
|
+
assert requests[-1].method == "DELETE"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Tests for the synchronous AgentKernel client."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pytest_httpx import HTTPXMock
|
|
5
|
+
|
|
6
|
+
from agentkernel import (
|
|
7
|
+
AgentKernel,
|
|
8
|
+
AuthError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
RunOutput,
|
|
11
|
+
SandboxInfo,
|
|
12
|
+
ServerError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
BASE_URL = "http://localhost:9999"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_client(**kwargs) -> AgentKernel:
|
|
20
|
+
return AgentKernel(base_url=BASE_URL, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestHealth:
|
|
24
|
+
def test_returns_ok(self, httpx_mock: HTTPXMock) -> None:
|
|
25
|
+
httpx_mock.add_response(json={"success": True, "data": "ok"})
|
|
26
|
+
assert make_client().health() == "ok"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestRun:
|
|
30
|
+
def test_returns_output(self, httpx_mock: HTTPXMock) -> None:
|
|
31
|
+
httpx_mock.add_response(json={"success": True, "data": {"output": "hello\n"}})
|
|
32
|
+
result = make_client().run(["echo", "hello"])
|
|
33
|
+
assert isinstance(result, RunOutput)
|
|
34
|
+
assert result.output == "hello\n"
|
|
35
|
+
|
|
36
|
+
def test_passes_options(self, httpx_mock: HTTPXMock) -> None:
|
|
37
|
+
httpx_mock.add_response(json={"success": True, "data": {"output": "ok\n"}})
|
|
38
|
+
make_client().run(
|
|
39
|
+
["python3", "-c", "print('ok')"],
|
|
40
|
+
image="python:3.12-alpine",
|
|
41
|
+
profile="restrictive",
|
|
42
|
+
fast=False,
|
|
43
|
+
)
|
|
44
|
+
request = httpx_mock.get_request()
|
|
45
|
+
assert request is not None
|
|
46
|
+
import json
|
|
47
|
+
|
|
48
|
+
body = json.loads(request.content)
|
|
49
|
+
assert body["image"] == "python:3.12-alpine"
|
|
50
|
+
assert body["profile"] == "restrictive"
|
|
51
|
+
assert body["fast"] is False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestListSandboxes:
|
|
55
|
+
def test_returns_list(self, httpx_mock: HTTPXMock) -> None:
|
|
56
|
+
httpx_mock.add_response(
|
|
57
|
+
json={
|
|
58
|
+
"success": True,
|
|
59
|
+
"data": [
|
|
60
|
+
{"name": "sb-1", "status": "running", "backend": "docker"},
|
|
61
|
+
{"name": "sb-2", "status": "stopped", "backend": "docker"},
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
result = make_client().list_sandboxes()
|
|
66
|
+
assert len(result) == 2
|
|
67
|
+
assert all(isinstance(s, SandboxInfo) for s in result)
|
|
68
|
+
assert result[0].name == "sb-1"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestCreateSandbox:
|
|
72
|
+
def test_creates(self, httpx_mock: HTTPXMock) -> None:
|
|
73
|
+
httpx_mock.add_response(
|
|
74
|
+
status_code=201,
|
|
75
|
+
json={"success": True, "data": {"name": "new", "status": "running", "backend": "docker"}},
|
|
76
|
+
)
|
|
77
|
+
result = make_client().create_sandbox("new")
|
|
78
|
+
assert result.name == "new"
|
|
79
|
+
assert result.status == "running"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestGetSandbox:
|
|
83
|
+
def test_returns_info(self, httpx_mock: HTTPXMock) -> None:
|
|
84
|
+
httpx_mock.add_response(
|
|
85
|
+
json={"success": True, "data": {"name": "test", "status": "running", "backend": "docker"}}
|
|
86
|
+
)
|
|
87
|
+
result = make_client().get_sandbox("test")
|
|
88
|
+
assert result.name == "test"
|
|
89
|
+
|
|
90
|
+
def test_not_found(self, httpx_mock: HTTPXMock) -> None:
|
|
91
|
+
httpx_mock.add_response(status_code=404, json={"success": False, "error": "Not found"})
|
|
92
|
+
with pytest.raises(NotFoundError):
|
|
93
|
+
make_client().get_sandbox("missing")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestRemoveSandbox:
|
|
97
|
+
def test_removes(self, httpx_mock: HTTPXMock) -> None:
|
|
98
|
+
httpx_mock.add_response(json={"success": True, "data": "Sandbox removed"})
|
|
99
|
+
make_client().remove_sandbox("test") # no exception
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestExecInSandbox:
|
|
103
|
+
def test_returns_output(self, httpx_mock: HTTPXMock) -> None:
|
|
104
|
+
httpx_mock.add_response(json={"success": True, "data": {"output": "result\n"}})
|
|
105
|
+
result = make_client().exec_in_sandbox("test", ["echo", "result"])
|
|
106
|
+
assert result.output == "result\n"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestSandboxSession:
|
|
110
|
+
def test_auto_removes(self, httpx_mock: HTTPXMock) -> None:
|
|
111
|
+
# create
|
|
112
|
+
httpx_mock.add_response(
|
|
113
|
+
json={"success": True, "data": {"name": "sess", "status": "running", "backend": "docker"}}
|
|
114
|
+
)
|
|
115
|
+
# remove
|
|
116
|
+
httpx_mock.add_response(json={"success": True, "data": "Sandbox removed"})
|
|
117
|
+
|
|
118
|
+
client = make_client()
|
|
119
|
+
with client.sandbox("sess") as sb:
|
|
120
|
+
assert sb.name == "sess"
|
|
121
|
+
# remove was called
|
|
122
|
+
requests = httpx_mock.get_requests()
|
|
123
|
+
assert requests[-1].method == "DELETE"
|
|
124
|
+
|
|
125
|
+
def test_remove_is_idempotent(self, httpx_mock: HTTPXMock) -> None:
|
|
126
|
+
httpx_mock.add_response(
|
|
127
|
+
json={"success": True, "data": {"name": "idem", "status": "running", "backend": "docker"}}
|
|
128
|
+
)
|
|
129
|
+
httpx_mock.add_response(json={"success": True, "data": "Sandbox removed"})
|
|
130
|
+
|
|
131
|
+
client = make_client()
|
|
132
|
+
sb = client.sandbox("idem")
|
|
133
|
+
sb.remove()
|
|
134
|
+
sb.remove() # second call is a no-op
|
|
135
|
+
delete_requests = [r for r in httpx_mock.get_requests() if r.method == "DELETE"]
|
|
136
|
+
assert len(delete_requests) == 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestAuth:
|
|
140
|
+
def test_sends_bearer_token(self, httpx_mock: HTTPXMock) -> None:
|
|
141
|
+
httpx_mock.add_response(json={"success": True, "data": "ok"})
|
|
142
|
+
make_client(api_key="sk-test").health()
|
|
143
|
+
request = httpx_mock.get_request()
|
|
144
|
+
assert request is not None
|
|
145
|
+
assert request.headers["authorization"] == "Bearer sk-test"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestErrors:
|
|
149
|
+
def test_401(self, httpx_mock: HTTPXMock) -> None:
|
|
150
|
+
httpx_mock.add_response(status_code=401, json={"success": False, "error": "Unauthorized"})
|
|
151
|
+
with pytest.raises(AuthError):
|
|
152
|
+
make_client().health()
|
|
153
|
+
|
|
154
|
+
def test_400(self, httpx_mock: HTTPXMock) -> None:
|
|
155
|
+
httpx_mock.add_response(status_code=400, json={"success": False, "error": "Bad request"})
|
|
156
|
+
with pytest.raises(ValidationError):
|
|
157
|
+
make_client().run([])
|
|
158
|
+
|
|
159
|
+
def test_500(self, httpx_mock: HTTPXMock) -> None:
|
|
160
|
+
httpx_mock.add_response(status_code=500, json={"success": False, "error": "Internal error"})
|
|
161
|
+
with pytest.raises(ServerError):
|
|
162
|
+
make_client().health()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestUserAgent:
|
|
166
|
+
def test_sends_user_agent(self, httpx_mock: HTTPXMock) -> None:
|
|
167
|
+
httpx_mock.add_response(json={"success": True, "data": "ok"})
|
|
168
|
+
make_client().health()
|
|
169
|
+
request = httpx_mock.get_request()
|
|
170
|
+
assert request is not None
|
|
171
|
+
assert request.headers["user-agent"].startswith("agentkernel-python-sdk/")
|