sqlsaber-notebook 0.2.0__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.
- sqlsaber_notebook-0.2.0/.gitignore +12 -0
- sqlsaber_notebook-0.2.0/CHANGELOG.md +10 -0
- sqlsaber_notebook-0.2.0/PKG-INFO +124 -0
- sqlsaber_notebook-0.2.0/README.md +112 -0
- sqlsaber_notebook-0.2.0/pyproject.toml +37 -0
- sqlsaber_notebook-0.2.0/pytest.ini +5 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/__init__.py +29 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/_shared.py +41 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/analyst.py +202 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/capability.py +540 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/cli.py +169 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/__init__.py +79 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/_files.py +102 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/base.py +238 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/docker.py +514 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/fake.py +156 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/modal.py +550 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/history.py +77 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/prompts.py +37 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/rendering.py +327 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/result.py +48 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/session.py +251 -0
- sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/tools.py +124 -0
- sqlsaber_notebook-0.2.0/tests/_notebooks.py +103 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_analyst.py +118 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_capability.py +419 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_cli.py +56 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_docker.py +88 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_docker_integration.py +103 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_execution.py +156 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_history.py +112 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_modal.py +132 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_modal_integration.py +84 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_rendering.py +121 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_session.py +114 -0
- sqlsaber_notebook-0.2.0/tests/test_notebook_tools.py +137 -0
- sqlsaber_notebook-0.2.0/uv.lock +3546 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/SarthakJariwala/sqlsaber/compare/sqlsaber-notebook-v0.1.0...sqlsaber-notebook-v0.2.0) (2026-07-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add sandboxed notebook analysis ([#199](https://github.com/SarthakJariwala/sqlsaber/issues/199)) ([c82590f](https://github.com/SarthakJariwala/sqlsaber/commit/c82590ffa8cb93649f021d548ff4d4c12171e54e))
|
|
9
|
+
* trigger release ([145d163](https://github.com/SarthakJariwala/sqlsaber/commit/145d163c91989e62b722a266842b13e9145be638))
|
|
10
|
+
* visualization model override support via api and prompt improvements ([35a506b](https://github.com/SarthakJariwala/sqlsaber/commit/35a506b23337dd64f8f20606e7e547755eae6f21))
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlsaber-notebook
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Notebook analysis subagent for SQLsaber
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: nbformat<6,>=5.10
|
|
7
|
+
Requires-Dist: pillow<13,>=11
|
|
8
|
+
Requires-Dist: sqlsaber>=0.69.0
|
|
9
|
+
Provides-Extra: modal
|
|
10
|
+
Requires-Dist: modal<2,>=1.4; extra == 'modal'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# SQLsaber Notebook
|
|
14
|
+
|
|
15
|
+
Notebook-specific data-analysis subagent for SQLsaber.
|
|
16
|
+
|
|
17
|
+
Implemented components:
|
|
18
|
+
|
|
19
|
+
- provider-neutral notebook execution contract,
|
|
20
|
+
- hardened local Docker execution (default),
|
|
21
|
+
- explicit remote Modal Sandbox execution through the optional `modal` extra,
|
|
22
|
+
- fresh-kernel transactional notebook sessions,
|
|
23
|
+
- bounded notebook/image rendering and history collapse,
|
|
24
|
+
- `list_workspace` and `edit_cell` analyst tools,
|
|
25
|
+
- a Pydantic AI notebook analyst,
|
|
26
|
+
- a managed SQLsaber `analyze_data` capability, and
|
|
27
|
+
- the standalone `sqlsaber-notebook` CLI.
|
|
28
|
+
|
|
29
|
+
When installed with SQLsaber, the main agent can hand prior successful SQL results to
|
|
30
|
+
`analyze_data` for multi-step calculations, statistics, transformations, and plots.
|
|
31
|
+
The terminal displays the bounded executed notebook and plot previews before the main
|
|
32
|
+
agent's text response. Notebook bytes and images are not sent to the parent model.
|
|
33
|
+
Managed SDK applications can persist the notebook, plots, and generated files through
|
|
34
|
+
`SQLSaberOptions.artifact_publisher`; only the publisher's durable references are
|
|
35
|
+
stored in tool metadata and exposed through `SQLSaberResult.artifacts`.
|
|
36
|
+
|
|
37
|
+
The default balanced runtime targets larger EDA and classical ML: 4 CPUs, 8 GiB
|
|
38
|
+
memory, and up to 100 MiB per input/250 MiB total. SQLsaber does not cap model
|
|
39
|
+
requests, notebook cell count, the analyst loop, or the whole operation. Individual
|
|
40
|
+
cells retain a 10-minute timeout so a stuck computation can be diagnosed without
|
|
41
|
+
ending the overall analysis. These are fixed product defaults rather than CLI tuning
|
|
42
|
+
flags. Use an immutable custom image through `SQLSABER_NOTEBOOK_IMAGE` when
|
|
43
|
+
additional ML libraries are required.
|
|
44
|
+
|
|
45
|
+
## Managed SQLsaber usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv tool install --with sqlsaber-notebook sqlsaber
|
|
49
|
+
saber
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Docker is the default local backend. Select Modal explicitly because query results
|
|
53
|
+
will be uploaded to a third party:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
SQLSABER_NOTEBOOK_BACKEND=modal saber
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Configure a dedicated analyst model with:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
saber models set --agent notebook
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For a web backend, inject an application-owned artifact publisher and pass tenant
|
|
66
|
+
scope as run metadata:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from sqlsaber import FilesystemArtifactPublisher, SQLSaber, SQLSaberOptions
|
|
70
|
+
|
|
71
|
+
options = SQLSaberOptions(
|
|
72
|
+
database="sqlite:///analytics.db",
|
|
73
|
+
artifact_publisher=FilesystemArtifactPublisher("/private/artifacts"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async with SQLSaber(options=options) as saber:
|
|
77
|
+
result = await saber.query(
|
|
78
|
+
"Analyze and plot revenue anomalies",
|
|
79
|
+
conversation_id="conversation-123",
|
|
80
|
+
metadata={"tenant_id": "acme"},
|
|
81
|
+
)
|
|
82
|
+
print(result.artifacts)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Implement the cloud-neutral `ArtifactPublisher` protocol to use S3, GCS, Azure
|
|
86
|
+
Blob Storage, or another bucket. Return stable object references rather than
|
|
87
|
+
expiring signed URLs.
|
|
88
|
+
|
|
89
|
+
## Standalone usage
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv run sqlsaber-notebook \
|
|
93
|
+
--model anthropic:claude-sonnet-4-6 \
|
|
94
|
+
--backend docker \
|
|
95
|
+
--output analysis.ipynb \
|
|
96
|
+
"Compare revenue by region and explain material anomalies" data.csv
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Modal is never selected as an automatic fallback. Select it explicitly because local
|
|
100
|
+
files will be uploaded to Modal:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
modal setup
|
|
104
|
+
SQLSABER_NOTEBOOK_BACKEND=modal uv run sqlsaber-notebook \
|
|
105
|
+
--model anthropic:claude-sonnet-4-6 \
|
|
106
|
+
"Analyze this dataset" data.csv
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv sync
|
|
113
|
+
uv run pytest plugins/notebook/tests -q
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Run live backend integration tests explicitly:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
SQLSABER_RUN_DOCKER_INTEGRATION=1 \
|
|
120
|
+
uv run pytest plugins/notebook/tests/test_notebook_docker_integration.py -q
|
|
121
|
+
|
|
122
|
+
SQLSABER_RUN_MODAL_INTEGRATION=1 \
|
|
123
|
+
uv run pytest plugins/notebook/tests/test_notebook_modal_integration.py -q
|
|
124
|
+
```
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SQLsaber Notebook
|
|
2
|
+
|
|
3
|
+
Notebook-specific data-analysis subagent for SQLsaber.
|
|
4
|
+
|
|
5
|
+
Implemented components:
|
|
6
|
+
|
|
7
|
+
- provider-neutral notebook execution contract,
|
|
8
|
+
- hardened local Docker execution (default),
|
|
9
|
+
- explicit remote Modal Sandbox execution through the optional `modal` extra,
|
|
10
|
+
- fresh-kernel transactional notebook sessions,
|
|
11
|
+
- bounded notebook/image rendering and history collapse,
|
|
12
|
+
- `list_workspace` and `edit_cell` analyst tools,
|
|
13
|
+
- a Pydantic AI notebook analyst,
|
|
14
|
+
- a managed SQLsaber `analyze_data` capability, and
|
|
15
|
+
- the standalone `sqlsaber-notebook` CLI.
|
|
16
|
+
|
|
17
|
+
When installed with SQLsaber, the main agent can hand prior successful SQL results to
|
|
18
|
+
`analyze_data` for multi-step calculations, statistics, transformations, and plots.
|
|
19
|
+
The terminal displays the bounded executed notebook and plot previews before the main
|
|
20
|
+
agent's text response. Notebook bytes and images are not sent to the parent model.
|
|
21
|
+
Managed SDK applications can persist the notebook, plots, and generated files through
|
|
22
|
+
`SQLSaberOptions.artifact_publisher`; only the publisher's durable references are
|
|
23
|
+
stored in tool metadata and exposed through `SQLSaberResult.artifacts`.
|
|
24
|
+
|
|
25
|
+
The default balanced runtime targets larger EDA and classical ML: 4 CPUs, 8 GiB
|
|
26
|
+
memory, and up to 100 MiB per input/250 MiB total. SQLsaber does not cap model
|
|
27
|
+
requests, notebook cell count, the analyst loop, or the whole operation. Individual
|
|
28
|
+
cells retain a 10-minute timeout so a stuck computation can be diagnosed without
|
|
29
|
+
ending the overall analysis. These are fixed product defaults rather than CLI tuning
|
|
30
|
+
flags. Use an immutable custom image through `SQLSABER_NOTEBOOK_IMAGE` when
|
|
31
|
+
additional ML libraries are required.
|
|
32
|
+
|
|
33
|
+
## Managed SQLsaber usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv tool install --with sqlsaber-notebook sqlsaber
|
|
37
|
+
saber
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Docker is the default local backend. Select Modal explicitly because query results
|
|
41
|
+
will be uploaded to a third party:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
SQLSABER_NOTEBOOK_BACKEND=modal saber
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Configure a dedicated analyst model with:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
saber models set --agent notebook
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For a web backend, inject an application-owned artifact publisher and pass tenant
|
|
54
|
+
scope as run metadata:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from sqlsaber import FilesystemArtifactPublisher, SQLSaber, SQLSaberOptions
|
|
58
|
+
|
|
59
|
+
options = SQLSaberOptions(
|
|
60
|
+
database="sqlite:///analytics.db",
|
|
61
|
+
artifact_publisher=FilesystemArtifactPublisher("/private/artifacts"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async with SQLSaber(options=options) as saber:
|
|
65
|
+
result = await saber.query(
|
|
66
|
+
"Analyze and plot revenue anomalies",
|
|
67
|
+
conversation_id="conversation-123",
|
|
68
|
+
metadata={"tenant_id": "acme"},
|
|
69
|
+
)
|
|
70
|
+
print(result.artifacts)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Implement the cloud-neutral `ArtifactPublisher` protocol to use S3, GCS, Azure
|
|
74
|
+
Blob Storage, or another bucket. Return stable object references rather than
|
|
75
|
+
expiring signed URLs.
|
|
76
|
+
|
|
77
|
+
## Standalone usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv run sqlsaber-notebook \
|
|
81
|
+
--model anthropic:claude-sonnet-4-6 \
|
|
82
|
+
--backend docker \
|
|
83
|
+
--output analysis.ipynb \
|
|
84
|
+
"Compare revenue by region and explain material anomalies" data.csv
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Modal is never selected as an automatic fallback. Select it explicitly because local
|
|
88
|
+
files will be uploaded to Modal:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
modal setup
|
|
92
|
+
SQLSABER_NOTEBOOK_BACKEND=modal uv run sqlsaber-notebook \
|
|
93
|
+
--model anthropic:claude-sonnet-4-6 \
|
|
94
|
+
"Analyze this dataset" data.csv
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv sync
|
|
101
|
+
uv run pytest plugins/notebook/tests -q
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Run live backend integration tests explicitly:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
SQLSABER_RUN_DOCKER_INTEGRATION=1 \
|
|
108
|
+
uv run pytest plugins/notebook/tests/test_notebook_docker_integration.py -q
|
|
109
|
+
|
|
110
|
+
SQLSABER_RUN_MODAL_INTEGRATION=1 \
|
|
111
|
+
uv run pytest plugins/notebook/tests/test_notebook_modal_integration.py -q
|
|
112
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqlsaber-notebook"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Notebook analysis subagent for SQLsaber"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"sqlsaber>=0.69.0",
|
|
9
|
+
"nbformat>=5.10,<6",
|
|
10
|
+
"pillow>=11,<13",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
modal = ["modal>=1.4,<2"]
|
|
15
|
+
|
|
16
|
+
[tool.uv.sources]
|
|
17
|
+
sqlsaber = { path = "../..", editable = true }
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
sqlsaber-notebook = "sqlsaber_notebook.cli:main"
|
|
21
|
+
|
|
22
|
+
[project.entry-points."sqlsaber.capabilities"]
|
|
23
|
+
notebook = "sqlsaber_notebook.capability:capability"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/sqlsaber_notebook"]
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=9.0.2",
|
|
35
|
+
"pytest-asyncio>=1.0.0",
|
|
36
|
+
"modal>=1.4,<2",
|
|
37
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""SQLsaber notebook analysis plugin."""
|
|
2
|
+
|
|
3
|
+
from .execution import (
|
|
4
|
+
DEFAULT_NOTEBOOK_BACKEND,
|
|
5
|
+
DEFAULT_NOTEBOOK_IMAGE,
|
|
6
|
+
ArtifactInfo,
|
|
7
|
+
DockerNotebookBackend,
|
|
8
|
+
ExecutionLimits,
|
|
9
|
+
NotebookBackend,
|
|
10
|
+
NotebookEnvironment,
|
|
11
|
+
NotebookExecutionResult,
|
|
12
|
+
NotebookInput,
|
|
13
|
+
resolve_notebook_backend,
|
|
14
|
+
resolve_notebook_image,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ArtifactInfo",
|
|
19
|
+
"DEFAULT_NOTEBOOK_BACKEND",
|
|
20
|
+
"DEFAULT_NOTEBOOK_IMAGE",
|
|
21
|
+
"DockerNotebookBackend",
|
|
22
|
+
"ExecutionLimits",
|
|
23
|
+
"NotebookBackend",
|
|
24
|
+
"NotebookEnvironment",
|
|
25
|
+
"NotebookExecutionResult",
|
|
26
|
+
"NotebookInput",
|
|
27
|
+
"resolve_notebook_backend",
|
|
28
|
+
"resolve_notebook_image",
|
|
29
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Central limits and defaults for notebook analysis."""
|
|
2
|
+
|
|
3
|
+
from .execution import DEFAULT_NOTEBOOK_IMAGE, ExecutionLimits
|
|
4
|
+
|
|
5
|
+
# Balanced defaults target larger EDA and classical ML while retaining finite
|
|
6
|
+
# model-context, host-memory, runtime, and cost boundaries.
|
|
7
|
+
MAX_DEFAULT_RESULTS = 20
|
|
8
|
+
MAX_WORKSPACE_FILES = 50
|
|
9
|
+
MAX_WORKSPACE_FILE_BYTES = 100 * 1024 * 1024
|
|
10
|
+
MAX_WORKSPACE_TOTAL_BYTES = 250 * 1024 * 1024
|
|
11
|
+
MAX_CELL_SOURCE_CHARS = 30_000
|
|
12
|
+
MAX_TOTAL_SOURCE_CHARS = 200_000
|
|
13
|
+
MAX_OUTPUT_CHARS = 6_000
|
|
14
|
+
MAX_SNAPSHOT_CHARS = 80_000
|
|
15
|
+
MAX_SNAPSHOT_IMAGES = 12
|
|
16
|
+
MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
|
17
|
+
MAX_SNAPSHOT_IMAGE_BYTES = 24 * 1024 * 1024
|
|
18
|
+
MAX_HISTORY_IMAGE_BYTES = 48 * 1024 * 1024
|
|
19
|
+
MAX_IMAGE_WIDTH = 2048
|
|
20
|
+
MAX_IMAGE_HEIGHT = 2048
|
|
21
|
+
MAX_GOAL_CHARS = 20_000
|
|
22
|
+
DEFAULT_EXECUTION_LIMITS = ExecutionLimits()
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DEFAULT_EXECUTION_LIMITS",
|
|
26
|
+
"DEFAULT_NOTEBOOK_IMAGE",
|
|
27
|
+
"MAX_CELL_SOURCE_CHARS",
|
|
28
|
+
"MAX_GOAL_CHARS",
|
|
29
|
+
"MAX_HISTORY_IMAGE_BYTES",
|
|
30
|
+
"MAX_IMAGE_BYTES",
|
|
31
|
+
"MAX_IMAGE_HEIGHT",
|
|
32
|
+
"MAX_IMAGE_WIDTH",
|
|
33
|
+
"MAX_OUTPUT_CHARS",
|
|
34
|
+
"MAX_SNAPSHOT_CHARS",
|
|
35
|
+
"MAX_SNAPSHOT_IMAGE_BYTES",
|
|
36
|
+
"MAX_SNAPSHOT_IMAGES",
|
|
37
|
+
"MAX_TOTAL_SOURCE_CHARS",
|
|
38
|
+
"MAX_WORKSPACE_FILES",
|
|
39
|
+
"MAX_WORKSPACE_FILE_BYTES",
|
|
40
|
+
"MAX_WORKSPACE_TOTAL_BYTES",
|
|
41
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""SQLsaber-independent notebook analyst orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import hashlib
|
|
8
|
+
from functools import partial
|
|
9
|
+
|
|
10
|
+
from pydantic_ai import Agent
|
|
11
|
+
from pydantic_ai.capabilities import ProcessHistory
|
|
12
|
+
from pydantic_ai.models import Model
|
|
13
|
+
from pydantic_ai.models.anthropic import AnthropicModelSettings
|
|
14
|
+
from pydantic_ai.settings import ModelSettings
|
|
15
|
+
from pydantic_ai.usage import RunUsage, UsageLimits
|
|
16
|
+
|
|
17
|
+
from ._shared import (
|
|
18
|
+
DEFAULT_EXECUTION_LIMITS,
|
|
19
|
+
MAX_GOAL_CHARS,
|
|
20
|
+
MAX_SNAPSHOT_IMAGE_BYTES,
|
|
21
|
+
MAX_SNAPSHOT_IMAGES,
|
|
22
|
+
)
|
|
23
|
+
from .execution import (
|
|
24
|
+
ExecutionLimits,
|
|
25
|
+
NotebookBackend,
|
|
26
|
+
resolve_notebook_backend,
|
|
27
|
+
resolve_notebook_image,
|
|
28
|
+
)
|
|
29
|
+
from .history import collapse_old_snapshots
|
|
30
|
+
from .prompts import ANALYST_SYSTEM_PROMPT, goal_prompt
|
|
31
|
+
from .rendering import extract_notebook_images, normalize_png_bytes
|
|
32
|
+
from .result import AnalysisResult, ArtifactRef, Workspace
|
|
33
|
+
from .session import NotebookSession
|
|
34
|
+
from .tools import analyst_toolset
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_analyst_agent(
|
|
38
|
+
model: Model | str,
|
|
39
|
+
*,
|
|
40
|
+
model_provider: str,
|
|
41
|
+
) -> Agent[NotebookSession, str]:
|
|
42
|
+
provider = model_provider.strip().lower()
|
|
43
|
+
return Agent(
|
|
44
|
+
model,
|
|
45
|
+
deps_type=NotebookSession,
|
|
46
|
+
output_type=str,
|
|
47
|
+
instructions=ANALYST_SYSTEM_PROMPT,
|
|
48
|
+
model_settings=_cache_settings(provider),
|
|
49
|
+
capabilities=[
|
|
50
|
+
ProcessHistory(
|
|
51
|
+
partial(
|
|
52
|
+
collapse_old_snapshots,
|
|
53
|
+
cache=provider == "anthropic",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
],
|
|
57
|
+
toolsets=[analyst_toolset()],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def analyze(
|
|
62
|
+
goal: str,
|
|
63
|
+
workspace: Workspace,
|
|
64
|
+
*,
|
|
65
|
+
model: Model | str,
|
|
66
|
+
model_provider: str,
|
|
67
|
+
backend: NotebookBackend | str | None = None,
|
|
68
|
+
image: str | None = None,
|
|
69
|
+
include_snapshot_images: bool = False,
|
|
70
|
+
collect_files: bool = True,
|
|
71
|
+
execution_limits: ExecutionLimits = DEFAULT_EXECUTION_LIMITS,
|
|
72
|
+
usage_limits: UsageLimits | None = None,
|
|
73
|
+
parent_usage: RunUsage | None = None,
|
|
74
|
+
) -> AnalysisResult:
|
|
75
|
+
"""Run one bounded notebook-analysis environment and always clean it up."""
|
|
76
|
+
|
|
77
|
+
if not goal.strip():
|
|
78
|
+
raise ValueError("Analysis goal cannot be empty")
|
|
79
|
+
if len(goal) > MAX_GOAL_CHARS:
|
|
80
|
+
raise ValueError(f"Analysis goal exceeds {MAX_GOAL_CHARS} characters")
|
|
81
|
+
selected_backend = (
|
|
82
|
+
backend
|
|
83
|
+
if isinstance(backend, NotebookBackend)
|
|
84
|
+
else resolve_notebook_backend(backend)
|
|
85
|
+
)
|
|
86
|
+
session = NotebookSession(
|
|
87
|
+
workspace=workspace,
|
|
88
|
+
backend=selected_backend,
|
|
89
|
+
image=resolve_notebook_image(image),
|
|
90
|
+
execution_limits=execution_limits,
|
|
91
|
+
include_snapshot_images=include_snapshot_images,
|
|
92
|
+
)
|
|
93
|
+
agent = build_analyst_agent(model, model_provider=model_provider)
|
|
94
|
+
child_usage = RunUsage()
|
|
95
|
+
try:
|
|
96
|
+
await session.ensure_environment()
|
|
97
|
+
result = await agent.run(
|
|
98
|
+
goal_prompt(goal),
|
|
99
|
+
deps=session,
|
|
100
|
+
usage=child_usage,
|
|
101
|
+
usage_limits=usage_limits,
|
|
102
|
+
)
|
|
103
|
+
images, files = await _harvest_artifacts(
|
|
104
|
+
session,
|
|
105
|
+
collect_files=collect_files,
|
|
106
|
+
)
|
|
107
|
+
return AnalysisResult(
|
|
108
|
+
answer=result.output,
|
|
109
|
+
notebook=session.notebook_bytes(),
|
|
110
|
+
images=images,
|
|
111
|
+
files=files,
|
|
112
|
+
provenance=_infer_provenance(session),
|
|
113
|
+
)
|
|
114
|
+
finally:
|
|
115
|
+
if parent_usage is not None:
|
|
116
|
+
parent_usage.incr(child_usage)
|
|
117
|
+
await _bounded_shielded_close(session)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def supports_notebook_images(model_name: str, model_provider: str) -> bool:
|
|
121
|
+
"""Conservative allowlist for analyst snapshot image input."""
|
|
122
|
+
|
|
123
|
+
provider = model_provider.strip().lower()
|
|
124
|
+
name = model_name.lower()
|
|
125
|
+
if provider == "anthropic":
|
|
126
|
+
return "claude" in name
|
|
127
|
+
if provider == "google":
|
|
128
|
+
return "gemini" in name
|
|
129
|
+
if provider == "openai":
|
|
130
|
+
return any(token in name for token in ("gpt-4", "gpt-5", "o3", "o4"))
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cache_settings(provider: str) -> ModelSettings:
|
|
135
|
+
if provider == "anthropic":
|
|
136
|
+
return AnthropicModelSettings(
|
|
137
|
+
parallel_tool_calls=False,
|
|
138
|
+
anthropic_cache_instructions=True,
|
|
139
|
+
anthropic_cache_tool_definitions=True,
|
|
140
|
+
)
|
|
141
|
+
return ModelSettings(parallel_tool_calls=False)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _harvest_artifacts(
|
|
145
|
+
session: NotebookSession,
|
|
146
|
+
*,
|
|
147
|
+
collect_files: bool,
|
|
148
|
+
) -> tuple[list[bytes], list[ArtifactRef]]:
|
|
149
|
+
images = extract_notebook_images(session.outputs)
|
|
150
|
+
image_hashes = {hashlib.sha256(image).hexdigest() for image in images}
|
|
151
|
+
image_bytes = sum(map(len, images))
|
|
152
|
+
files: list[ArtifactRef] = []
|
|
153
|
+
if session.environment is None:
|
|
154
|
+
return images, files
|
|
155
|
+
|
|
156
|
+
for artifact in session.artifacts:
|
|
157
|
+
is_png = artifact.media_type == "image/png" or artifact.path.lower().endswith(
|
|
158
|
+
".png"
|
|
159
|
+
)
|
|
160
|
+
if not is_png and not collect_files:
|
|
161
|
+
continue
|
|
162
|
+
data = await session.environment.read_artifact(artifact)
|
|
163
|
+
if is_png:
|
|
164
|
+
normalized = normalize_png_bytes(data)
|
|
165
|
+
if normalized is None:
|
|
166
|
+
continue
|
|
167
|
+
digest = hashlib.sha256(normalized).hexdigest()
|
|
168
|
+
if digest in image_hashes or len(images) >= MAX_SNAPSHOT_IMAGES:
|
|
169
|
+
continue
|
|
170
|
+
if image_bytes + len(normalized) > MAX_SNAPSHOT_IMAGE_BYTES:
|
|
171
|
+
continue
|
|
172
|
+
image_hashes.add(digest)
|
|
173
|
+
image_bytes += len(normalized)
|
|
174
|
+
images.append(normalized)
|
|
175
|
+
elif collect_files:
|
|
176
|
+
files.append(
|
|
177
|
+
ArtifactRef(
|
|
178
|
+
name=artifact.path,
|
|
179
|
+
data=data,
|
|
180
|
+
media_type=artifact.media_type or "application/octet-stream",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return images, files
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _infer_provenance(session: NotebookSession) -> list[str]:
|
|
187
|
+
provenance: list[str] = []
|
|
188
|
+
for item in session.workspace.files:
|
|
189
|
+
token = f"../inputs/{item.name}"
|
|
190
|
+
if any(token in source or item.name in source for source in session.cells):
|
|
191
|
+
provenance.append(f"input:{item.name}")
|
|
192
|
+
provenance.extend(
|
|
193
|
+
f"cell:{index}" for index, source in enumerate(session.cells) if source.strip()
|
|
194
|
+
)
|
|
195
|
+
return provenance
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def _bounded_shielded_close(session: NotebookSession) -> None:
|
|
199
|
+
task = asyncio.create_task(session.close())
|
|
200
|
+
with contextlib.suppress(Exception):
|
|
201
|
+
async with asyncio.timeout(30):
|
|
202
|
+
await asyncio.shield(task)
|