Skip to content

Commit 3f3ed0f

Browse files
DeanChensjcopybara-github
authored andcommitted
ADK changes
Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 890694124
1 parent f973673 commit 3f3ed0f

File tree

3 files changed

+311
-0
lines changed

3 files changed

+311
-0
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Agent environments."""
16+
17+
from __future__ import annotations
18+
19+
from ._base_environment import BaseEnvironment
20+
from ._base_environment import ExecutionResult
21+
from ._local_environment import LocalEnvironment
22+
23+
__all__ = [
24+
'BaseEnvironment',
25+
'ExecutionResult',
26+
'LocalEnvironment',
27+
]
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Base class for agent environments."""
16+
17+
from __future__ import annotations
18+
19+
from abc import ABC
20+
from abc import abstractmethod
21+
import dataclasses
22+
from pathlib import Path
23+
from typing import Optional
24+
25+
from ..utils.feature_decorator import experimental
26+
27+
28+
@dataclasses.dataclass
29+
class ExecutionResult:
30+
"""Result of a command execution."""
31+
32+
exit_code: int = 0
33+
"""The exit code of the process."""
34+
35+
stdout: str = ""
36+
"""Standard output captured from the process."""
37+
38+
stderr: str = ""
39+
"""Standard error captured from the process."""
40+
41+
timed_out: bool = False
42+
"""Whether the execution exceeded the timeout."""
43+
44+
45+
@experimental
46+
class BaseEnvironment(ABC):
47+
"""Abstract base class for code execution environments.
48+
49+
An environment provides the ability to execute shell commands,
50+
read files, and write files within a working directory. Concrete
51+
implementations include local subprocess execution, sandboxed
52+
execution, container environments, and cloud-hosted environments.
53+
54+
Lifecycle:
55+
1. Construct the environment (``__init__``).
56+
2. Call ``initialize()`` before first use.
57+
3. Use ``execute``, ``read_file``, ``write_file``.
58+
4. Call ``close()`` when done.
59+
"""
60+
61+
async def initialize(self) -> None:
62+
"""Initialize the environment (e.g. create working directory).
63+
64+
Called before first use. The default implementation is a
65+
no-op. Sub-classes should ensure this method is idempotent.
66+
"""
67+
68+
async def close(self) -> None:
69+
"""Release resources held by the environment.
70+
71+
Called when the environment is no longer needed. The default
72+
implementation is a no-op. Sub-classes should ensure this method is
73+
idempotent.
74+
"""
75+
76+
@property
77+
@abstractmethod
78+
def working_dir(self) -> Path:
79+
"""The absolute path to the environment's working directory."""
80+
81+
@abstractmethod
82+
async def execute(
83+
self,
84+
command: str,
85+
*,
86+
timeout: Optional[float] = None,
87+
) -> ExecutionResult:
88+
"""Execute a shell command in the working directory.
89+
90+
Args:
91+
command: The shell command string to execute.
92+
timeout: Maximum execution time in seconds. ``None`` means
93+
no limit.
94+
95+
Returns:
96+
An ``ExecutionResult`` with exit code, stdout, stderr, and
97+
timeout status.
98+
"""
99+
100+
@abstractmethod
101+
async def read_file(self, path: Path) -> bytes:
102+
"""Read a file from the environment filesystem.
103+
104+
Args:
105+
path: Absolute or working-dir-relative path to the file.
106+
107+
Returns:
108+
The raw file contents as bytes.
109+
110+
Raises:
111+
FileNotFoundError: If the file does not exist.
112+
"""
113+
114+
@abstractmethod
115+
async def write_file(self, path: Path, content: str | bytes) -> None:
116+
"""Write content to a file in the environment's filesystem.
117+
118+
Parent directories are created automatically if they do not
119+
exist.
120+
121+
Args:
122+
path: Absolute or working-dir-relative path to the file.
123+
content: The string or raw bytes to write.
124+
"""
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Local subprocess code execution environment."""
16+
17+
from __future__ import annotations
18+
19+
import asyncio
20+
import logging
21+
import os
22+
from pathlib import Path
23+
import shutil
24+
import tempfile
25+
from typing import Optional
26+
27+
from typing_extensions import override
28+
29+
from ..utils.feature_decorator import experimental
30+
from .base_environment import BaseEnvironment
31+
from .base_environment import ExecutionResult
32+
33+
logger = logging.getLogger('google_adk.' + __name__)
34+
35+
36+
@experimental
37+
class LocalEnvironment(BaseEnvironment):
38+
"""Execute commands via local ``asyncio`` subprocesses.
39+
40+
When ``working_dir`` is not specified, a temporary directory is
41+
created on ``initialize()`` and removed on ``close()``.
42+
"""
43+
44+
def __init__(
45+
self,
46+
*,
47+
working_dir: Optional[Path] = None,
48+
env_vars: Optional[dict[str, str]] = None,
49+
):
50+
"""Create a local environment.
51+
52+
Args:
53+
working_dir: Absolute path to the workspace directory. If
54+
``None``, a temporary directory is created during
55+
``initialize()``.
56+
env_vars: Extra environment variables merged into the subprocess
57+
environment.
58+
"""
59+
self._working_dir = working_dir
60+
self._env_vars = env_vars
61+
self._auto_created = False
62+
63+
@property
64+
@override
65+
def working_dir(self) -> Path:
66+
if self._working_dir is None:
67+
raise RuntimeError('`working_dir` is not set. Call initialize() first.')
68+
return self._working_dir
69+
70+
@override
71+
async def initialize(self) -> None:
72+
if self._working_dir is None:
73+
self._working_dir = Path(tempfile.mkdtemp(prefix='adk_workspace_'))
74+
self._auto_created = True
75+
logger.debug('Created temporary folder: %s', self._working_dir)
76+
else:
77+
os.makedirs(self._working_dir, exist_ok=True)
78+
79+
@override
80+
async def close(self) -> None:
81+
if self._auto_created and self._working_dir:
82+
shutil.rmtree(self._working_dir, ignore_errors=True)
83+
logger.debug('Removed temporary workspace: %s', self._working_dir)
84+
self._working_dir = None
85+
86+
87+
@override
88+
async def execute(
89+
self,
90+
command: str,
91+
*,
92+
timeout: Optional[float] = None,
93+
) -> ExecutionResult:
94+
if self._working_dir is None:
95+
raise RuntimeError('`working_dir` is not set. Call initialize() first.')
96+
97+
proc_env = os.environ.copy()
98+
if self._env_vars:
99+
proc_env.update(self._env_vars)
100+
101+
proc = await asyncio.create_subprocess_shell(
102+
command,
103+
stdout=asyncio.subprocess.PIPE,
104+
stderr=asyncio.subprocess.PIPE,
105+
cwd=self._working_dir,
106+
env=proc_env,
107+
)
108+
109+
timed_out = False
110+
try:
111+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
112+
proc.communicate(), timeout=timeout
113+
)
114+
except asyncio.TimeoutError:
115+
timed_out = True
116+
proc.kill()
117+
stdout_bytes, stderr_bytes = await proc.communicate()
118+
119+
return ExecutionResult(
120+
exit_code=proc.returncode or 0,
121+
stdout=stdout_bytes.decode('utf-8', errors='replace'),
122+
stderr=stderr_bytes.decode('utf-8', errors='replace'),
123+
timed_out=timed_out,
124+
)
125+
126+
@override
127+
async def read_file(self, path: str) -> bytes:
128+
if self._working_dir is None:
129+
raise RuntimeError('`working_dir` is not set. Call initialize() first.')
130+
131+
path = self._resolve_path(path)
132+
return await asyncio.to_thread(self._sync_read, path)
133+
134+
@override
135+
async def write_file(self, path: str, content: str | bytes) -> None:
136+
if self._working_dir is None:
137+
raise RuntimeError('`working_dir` is not set. Call initialize() first.')
138+
139+
path = self._resolve_path(path)
140+
return await asyncio.to_thread(self._sync_write, path, content)
141+
142+
143+
def _resolve_path(self, path: str) -> str:
144+
"""Resolve a relative path against the working directory."""
145+
if os.path.isabs(path):
146+
return path
147+
return os.path.join(self._working_dir, path)
148+
149+
@staticmethod
150+
def _sync_read(path: str) -> bytes:
151+
with open(path, 'rb') as f:
152+
return f.read()
153+
154+
@staticmethod
155+
def _sync_write(path: str, content: str | bytes) -> None:
156+
os.makedirs(os.path.dirname(path), exist_ok=True)
157+
mode = 'w' if isinstance(content, str) else 'wb'
158+
kwargs = {'encoding': 'utf-8'} if isinstance(content, str) else {}
159+
with open(path, mode, **kwargs) as f:
160+
f.write(content)

0 commit comments

Comments
 (0)