Skip to content

Commit f641b1a

Browse files
shukladivyanshcopybara-github
authored andcommitted
feat: Implement robust process group management and timeouts in BashTool
Refactors BashTool to use start_new_session=True and os.killpg for proper process isolation and cleanup on timeouts or errors. PiperOrigin-RevId: 893740978
1 parent 33d4847 commit f641b1a

File tree

5 files changed

+73
-31
lines changed

5 files changed

+73
-31
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "1.28.1"
2+
".": "1.28.0"
33
}

CHANGELOG.md

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,5 @@
11
# Changelog
22

3-
## [1.28.1](https://github.com/google/adk-python/compare/v1.28.0...v1.28.1) (2026-04-02)
4-
5-
6-
### Features
7-
8-
* **live:** support live for `gemini-3.1-flash-live-preview` model ([ee69661](https://github.com/google/adk-python/commit/ee69661a616056fa89e0ec2188aaa59bd714d8c9))
9-
10-
11-
### Bug Fixes
12-
13-
* Disallow args on /builder and Add warning about Web UI usage to CLI help ([f037f68](https://github.com/google/adk-python/commit/f037f68d67ae1bd16b00df0c7523fb67cbd1e911))
14-
* **live:** Buffer tool calls and emit them together upon turn completion ([081adbd](https://github.com/google/adk-python/commit/081adbdfa41490e4868b028a1cdabceb811a7505))
15-
163
## [1.28.0](https://github.com/google/adk-python/compare/v1.27.5...v1.28.0) (2026-03-26)
174

185

src/google/adk/tools/bash_tool.py

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818

1919
import asyncio
2020
import dataclasses
21+
import logging
22+
import os
2123
import pathlib
2224
import shlex
25+
import signal
2326
from typing import Any
2427
from typing import Optional
2528

@@ -132,26 +135,74 @@ async def run_async(
132135
elif not tool_context.tool_confirmation.confirmed:
133136
return {"error": "This tool call is rejected."}
134137

135-
process = await asyncio.create_subprocess_exec(
136-
*shlex.split(command),
137-
cwd=str(self._workspace),
138-
stdout=asyncio.subprocess.PIPE,
139-
stderr=asyncio.subprocess.PIPE,
140-
)
138+
stdout = None
139+
stderr = None
141140
try:
142-
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
141+
process = await asyncio.create_subprocess_exec(
142+
*shlex.split(command),
143+
cwd=str(self._workspace),
144+
stdout=asyncio.subprocess.PIPE,
145+
stderr=asyncio.subprocess.PIPE,
146+
start_new_session=True,
147+
)
148+
149+
try:
150+
stdout, stderr = await asyncio.wait_for(
151+
process.communicate(), timeout=30
152+
)
153+
except asyncio.TimeoutError:
154+
try:
155+
os.killpg(process.pid, signal.SIGKILL)
156+
except ProcessLookupError:
157+
pass
158+
stdout, stderr = await process.communicate()
159+
return {
160+
"error": "Command timed out after 30 seconds.",
161+
"stdout": (
162+
stdout.decode(errors="replace")
163+
if stdout
164+
else "<no stdout captured>"
165+
),
166+
"stderr": (
167+
stderr.decode(errors="replace")
168+
if stderr
169+
else "<no stderr captured>"
170+
),
171+
"returncode": process.returncode,
172+
}
173+
finally:
174+
try:
175+
if process.pid:
176+
os.killpg(process.pid, signal.SIGKILL)
177+
except ProcessLookupError:
178+
pass
179+
143180
return {
144181
"stdout": (
145-
stdout.decode() if stdout is not None else "<No stdout captured>"
182+
stdout.decode(errors="replace")
183+
if stdout
184+
else "<no stdout captured>"
146185
),
147186
"stderr": (
148-
stderr.decode() if stderr is not None else "<No stderr captured>"
187+
stderr.decode(errors="replace")
188+
if stderr
189+
else "<no stderr captured>"
149190
),
150191
"returncode": process.returncode,
151192
}
152-
except asyncio.TimeoutError:
153-
try:
154-
process.kill()
155-
except ProcessLookupError:
156-
pass
157-
return {"error": "Command timed out after 30 seconds."}
193+
except Exception as e: # pylint: disable=broad-except
194+
logger = logging.getLogger("google_adk." + __name__)
195+
logger.exception("ExecuteBashTool execution failed")
196+
197+
stdout_res = (
198+
stdout.decode(errors="replace") if stdout else "<no stdout captured>"
199+
)
200+
stderr_res = (
201+
stderr.decode(errors="replace") if stderr else "<no stderr captured>"
202+
)
203+
204+
return {
205+
"error": f"Execution failed: {str(e)}",
206+
"stdout": stdout_res,
207+
"stderr": stderr_res,
208+
}

src/google/adk/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
# limitations under the License.
1414

1515
# version: major.minor.patch
16-
__version__ = "1.28.1"
16+
__version__ = "1.28.0"

tests/unittests/tools/test_bash_tool.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import asyncio
16+
import signal
1617
from unittest import mock
1718

1819
from google.adk.tools import bash_tool
@@ -203,6 +204,8 @@ async def test_nonzero_returncode(self, workspace, tool_context_confirmed):
203204
async def test_timeout(self, workspace, tool_context_confirmed):
204205
tool = bash_tool.ExecuteBashTool(workspace=workspace)
205206
mock_process = mock.AsyncMock()
207+
mock_process.pid = 12345
208+
mock_process.communicate.return_value = (b"", b"")
206209
with (
207210
mock.patch.object(
208211
asyncio,
@@ -213,14 +216,15 @@ async def test_timeout(self, workspace, tool_context_confirmed):
213216
mock.patch.object(
214217
asyncio, "wait_for", autospec=True, side_effect=asyncio.TimeoutError
215218
),
219+
mock.patch("os.killpg") as mock_killpg,
216220
):
217221
result = await tool.run_async(
218222
args={"command": "python scripts/do_thing.py"},
219223
tool_context=tool_context_confirmed,
220224
)
225+
mock_killpg.assert_called_with(12345, signal.SIGKILL)
221226
assert "error" in result
222227
assert "timed out" in result["error"].lower()
223-
mock_process.kill.assert_called_once()
224228

225229
@pytest.mark.asyncio
226230
async def test_cwd_is_workspace(self, workspace, tool_context_confirmed):

0 commit comments

Comments
 (0)