Skip to content

Commit 34bc82e

Browse files
authored
Jupyter Code Executor in v0.4 (alternative implementation) (microsoft#4885)
1 parent 918292f commit 34bc82e

File tree

7 files changed

+457
-0
lines changed

7 files changed

+457
-0
lines changed

python/packages/autogen-core/docs/src/reference/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ python/autogen_ext.tools.code_execution
5858
python/autogen_ext.tools.semantic_kernel
5959
python/autogen_ext.code_executors.local
6060
python/autogen_ext.code_executors.docker
61+
python/autogen_ext.code_executors.jupyter
6162
python/autogen_ext.code_executors.azure
6263
python/autogen_ext.cache_store.diskcache
6364
python/autogen_ext.cache_store.redis
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
autogen\_ext.code\_executors.jupyter
2+
====================================
3+
4+
5+
.. automodule:: autogen_ext.code_executors.jupyter
6+
:members:
7+
:undoc-members:
8+
:show-inheritance:

python/packages/autogen-ext/pyproject.toml

+4
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ redis = [
5656
grpc = [
5757
"grpcio~=1.62.0", # TODO: update this once we have a stable version.
5858
]
59+
jupyter-executor = [
60+
"ipykernel>=6.29.5",
61+
"nbclient>=0.10.2",
62+
]
5963

6064
semantic-kernel-core = [
6165
"semantic-kernel>=1.17.1",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from ._jupyter_code_executor import JupyterCodeExecutor, JupyterCodeResult
2+
3+
__all__ = [
4+
"JupyterCodeExecutor",
5+
"JupyterCodeResult",
6+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import asyncio
2+
import base64
3+
import json
4+
import re
5+
import sys
6+
import uuid
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
from types import TracebackType
10+
11+
if sys.version_info >= (3, 11):
12+
from typing import Self
13+
else:
14+
from typing_extensions import Self
15+
16+
from autogen_core import CancellationToken
17+
from autogen_core.code_executor import CodeBlock, CodeExecutor, CodeResult
18+
from nbclient import NotebookClient
19+
from nbformat import NotebookNode
20+
from nbformat import v4 as nbformat
21+
22+
from .._common import silence_pip
23+
24+
25+
@dataclass
26+
class JupyterCodeResult(CodeResult):
27+
"""A code result class for Jupyter code executor."""
28+
29+
output_files: list[Path]
30+
31+
32+
class JupyterCodeExecutor(CodeExecutor):
33+
"""A code executor class that executes code statefully using [nbclient](https://github.com/jupyter/nbclient).
34+
35+
.. danger::
36+
37+
This will execute code on the local machine. If being used with LLM generated code, caution should be used.
38+
39+
Example of using it directly:
40+
41+
.. code-block:: python
42+
43+
import asyncio
44+
from autogen_core import CancellationToken
45+
from autogen_core.code_executor import CodeBlock
46+
from autogen_ext.code_executors.jupyter import JupyterCodeExecutor
47+
48+
49+
async def main() -> None:
50+
async with JupyterCodeExecutor() as executor:
51+
cancel_token = CancellationToken()
52+
code_blocks = [CodeBlock(code="print('hello world!')", language="python")]
53+
code_result = await executor.execute_code_blocks(code_blocks, cancel_token)
54+
print(code_result)
55+
56+
57+
asyncio.run(main())
58+
59+
Example of using it with :class:`~autogen_ext.tools.code_execution.PythonCodeExecutionTool`:
60+
61+
.. code-block:: python
62+
63+
import asyncio
64+
from autogen_agentchat.agents import AssistantAgent
65+
from autogen_ext.code_executors.jupyter import JupyterCodeExecutor
66+
from autogen_ext.models.openai import OpenAIChatCompletionClient
67+
from autogen_ext.tools.code_execution import PythonCodeExecutionTool
68+
69+
70+
async def main() -> None:
71+
async with JupyterCodeExecutor() as executor:
72+
tool = PythonCodeExecutionTool(executor)
73+
model_client = OpenAIChatCompletionClient(model="gpt-4o")
74+
agent = AssistantAgent("assistant", model_client=model_client, tools=[tool])
75+
result = await agent.run(task="What is the 10th Fibonacci number? Use Python to calculate it.")
76+
print(result)
77+
78+
79+
asyncio.run(main())
80+
81+
Example of using it inside a :class:`~autogen_agentchat.agents._code_executor_agent.CodeExecutorAgent`:
82+
83+
.. code-block:: python
84+
85+
import asyncio
86+
from autogen_agentchat.agents import CodeExecutorAgent
87+
from autogen_agentchat.messages import TextMessage
88+
from autogen_ext.code_executors.jupyter import JupyterCodeExecutor
89+
from autogen_core import CancellationToken
90+
91+
92+
async def main() -> None:
93+
async with JupyterCodeExecutor() as executor:
94+
code_executor_agent = CodeExecutorAgent("code_executor", code_executor=executor)
95+
task = TextMessage(
96+
content='''Here is some code
97+
```python
98+
print('Hello world')
99+
```
100+
''',
101+
source="user",
102+
)
103+
response = await code_executor_agent.on_messages([task], CancellationToken())
104+
print(response.chat_message)
105+
106+
107+
asyncio.run(main())
108+
109+
110+
Args:
111+
kernel_name (str): The kernel name to use. By default, "python3".
112+
timeout (int): The timeout for code execution, by default 60.
113+
output_dir (Path): The directory to save output files, by default ".".
114+
"""
115+
116+
def __init__(
117+
self,
118+
kernel_name: str = "python3",
119+
timeout: int = 60,
120+
output_dir: Path = Path("."),
121+
):
122+
if timeout < 1:
123+
raise ValueError("Timeout must be greater than or equal to 1.")
124+
125+
self._kernel_name = kernel_name
126+
self._timeout = timeout
127+
self._output_dir = output_dir
128+
# TODO: Forward arguments perhaps?
129+
self._client = NotebookClient(
130+
nb=nbformat.new_notebook(), # type: ignore
131+
kernel_name=self._kernel_name,
132+
timeout=self._timeout,
133+
allow_errors=True,
134+
)
135+
136+
async def execute_code_blocks(
137+
self, code_blocks: list[CodeBlock], cancellation_token: CancellationToken
138+
) -> JupyterCodeResult:
139+
"""Execute code blocks and return the result.
140+
141+
Args:
142+
code_blocks (list[CodeBlock]): The code blocks to execute.
143+
144+
Returns:
145+
JupyterCodeResult: The result of the code execution.
146+
"""
147+
outputs: list[str] = []
148+
output_files: list[Path] = []
149+
exit_code = 0
150+
151+
for code_block in code_blocks:
152+
result = await self._execute_code_block(code_block, cancellation_token)
153+
exit_code = result.exit_code
154+
outputs.append(result.output)
155+
output_files.extend(result.output_files)
156+
157+
# Stop execution if one code block fails
158+
if exit_code != 0:
159+
break
160+
161+
return JupyterCodeResult(exit_code=exit_code, output="\n".join(outputs), output_files=output_files)
162+
163+
async def _execute_code_block(
164+
self, code_block: CodeBlock, cancellation_token: CancellationToken
165+
) -> JupyterCodeResult:
166+
"""Execute single code block and return the result.
167+
168+
Args:
169+
code_block (CodeBlock): The code block to execute.
170+
171+
Returns:
172+
JupyterCodeResult: The result of the code execution.
173+
"""
174+
execute_task = asyncio.create_task(
175+
self._execute_cell(
176+
nbformat.new_code_cell(silence_pip(code_block.code, code_block.language)) # type: ignore
177+
)
178+
)
179+
180+
cancellation_token.link_future(execute_task)
181+
output_cell = await asyncio.wait_for(asyncio.shield(execute_task), timeout=self._timeout)
182+
183+
outputs: list[str] = []
184+
output_files: list[Path] = []
185+
exit_code = 0
186+
187+
for output in output_cell.get("outputs", []):
188+
match output.get("output_type"):
189+
case "stream":
190+
outputs.append(output.get("text", ""))
191+
case "error":
192+
traceback = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", "\n".join(output["traceback"]))
193+
outputs.append(traceback)
194+
exit_code = 1
195+
case "execute_result" | "display_data":
196+
data = output.get("data", {})
197+
for mime, content in data.items():
198+
match mime:
199+
case "text/plain":
200+
outputs.append(content)
201+
case "image/png":
202+
path = self._save_image(content)
203+
output_files.append(path)
204+
case "image/jpeg":
205+
# TODO: Should this also be encoded? Images are encoded as both png and jpg
206+
pass
207+
case "text/html":
208+
path = self._save_html(content)
209+
output_files.append(path)
210+
case _:
211+
outputs.append(json.dumps(content))
212+
case _:
213+
pass
214+
215+
return JupyterCodeResult(exit_code=exit_code, output="\n".join(outputs), output_files=output_files)
216+
217+
async def _execute_cell(self, cell: NotebookNode) -> NotebookNode:
218+
# Temporary push cell to nb as async_execute_cell expects it. But then we want to remove it again as cells can take up significant amount of memory (especially with images)
219+
self._client.nb.cells.append(cell)
220+
output = await self._client.async_execute_cell(
221+
cell,
222+
cell_index=0,
223+
)
224+
self._client.nb.cells.pop()
225+
return output
226+
227+
def _save_image(self, image_data_base64: str) -> Path:
228+
"""Save image data to a file."""
229+
image_data = base64.b64decode(image_data_base64)
230+
path = self._output_dir / f"{uuid.uuid4().hex}.png"
231+
path.write_bytes(image_data)
232+
return path.absolute()
233+
234+
def _save_html(self, html_data: str) -> Path:
235+
"""Save HTML data to a file."""
236+
path = self._output_dir / f"{uuid.uuid4().hex}.html"
237+
path.write_text(html_data)
238+
return path.absolute()
239+
240+
async def restart(self) -> None:
241+
"""Restart the code executor."""
242+
await self.stop()
243+
await self.start()
244+
245+
async def start(self) -> None:
246+
self.kernel_context = self._client.async_setup_kernel()
247+
await self.kernel_context.__aenter__()
248+
249+
async def stop(self) -> None:
250+
"""Stop the kernel."""
251+
await self.kernel_context.__aexit__(None, None, None)
252+
253+
async def __aenter__(self) -> Self:
254+
await self.start()
255+
return self
256+
257+
async def __aexit__(
258+
self,
259+
exc_type: type[BaseException] | None,
260+
exc_val: BaseException | None,
261+
exc_tb: TracebackType | None,
262+
) -> None:
263+
await self.stop()

0 commit comments

Comments
 (0)