Skip to content

Commit b7ad76a

Browse files
VectorPeakwukath
authored andcommitted
fix: decode shell skill output as UTF-8
Merge #6288 ## Summary - Decode shell skill output as UTF-8 to keep it independent from host locale. - Preserve shell exit code with stderr output. Closes #6289 ## Testing - Activated virtual environment in open_source_workspace. - Ran `pytest tests/unittests/tools/test_skill_toolset.py`. - 113 tests passed. Co-authored-by: Kathy Wu <[email protected]> PiperOrigin-RevId: 948464268
1 parent 3164504 commit b7ad76a

2 files changed

Lines changed: 37 additions & 6 deletions

File tree

src/google/adk/tools/skill_toolset.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -564,8 +564,13 @@ async def execute_script_async(
564564
stdout = parsed.get("stdout", "")
565565
stderr = parsed.get("stderr", "")
566566
rc = parsed.get("returncode", 0)
567-
if rc != 0 and not stderr:
568-
stderr = f"Exit code {rc}"
567+
if rc != 0 and not parsed.get("timeout", False):
568+
exit_code_message = f"Exit code {rc}"
569+
stderr = (
570+
f"{stderr.rstrip()}\n{exit_code_message}"
571+
if stderr
572+
else exit_code_message
573+
)
569574
except (json.JSONDecodeError, ValueError):
570575
pass
571576

@@ -745,6 +750,8 @@ def _build_wrapper_code(
745750
" _r = subprocess.run(",
746751
f" {arr!r},",
747752
" capture_output=True, text=True,",
753+
# Keep shell output decoding independent from the host locale.
754+
" encoding='utf-8', errors='replace',",
748755
f" timeout={timeout!r}, cwd=td,",
749756
" )",
750757
" print(_json.dumps({",
@@ -759,6 +766,7 @@ def _build_wrapper_code(
759766
" 'stdout': _e.stdout or '',",
760767
f" 'stderr': 'Timed out after {timeout}s',",
761768
" 'returncode': -1,",
769+
" 'timeout': True,",
762770
" }))",
763771
])
764772
else:

tests/unittests/tools/test_skill_toolset.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import asyncio
1717
import collections
18+
import json
1819
import logging
1920
import sys
2021
from unittest import mock
@@ -996,6 +997,8 @@ async def test_execute_script_shell_success(mock_skill1):
996997
code_input = call_args[0][1]
997998
assert "subprocess.run" in code_input.code
998999
assert "bash" in code_input.code
1000+
assert "encoding='utf-8'" in code_input.code
1001+
assert "errors='replace'" in code_input.code
9991002
assert "__shell_result__" in code_input.code
10001003

10011004

@@ -1583,7 +1586,6 @@ async def test_integration_shell_stderr_only():
15831586
@pytest.mark.asyncio
15841587
async def test_shell_json_envelope_parsed(mock_skill1):
15851588
"""Shell JSON envelope is correctly unpacked by run_async."""
1586-
import json
15871589

15881590
envelope = json.dumps({
15891591
"__shell_result__": True,
@@ -1607,7 +1609,6 @@ async def test_shell_json_envelope_parsed(mock_skill1):
16071609
@pytest.mark.asyncio
16081610
async def test_shell_json_envelope_nonzero_returncode(mock_skill1):
16091611
"""Non-zero returncode in shell envelope sets stderr."""
1610-
import json
16111612

16121613
envelope = json.dumps({
16131614
"__shell_result__": True,
@@ -1627,10 +1628,31 @@ async def test_shell_json_envelope_nonzero_returncode(mock_skill1):
16271628
assert "Exit code 2" in result["stderr"]
16281629

16291630

1631+
@pytest.mark.asyncio
1632+
async def test_shell_json_envelope_nonzero_returncode_with_stderr(mock_skill1):
1633+
"""Non-zero returncode in shell envelope appends exit code to stderr."""
1634+
1635+
envelope = json.dumps({
1636+
"__shell_result__": True,
1637+
"stdout": "",
1638+
"stderr": "some error occurred",
1639+
"returncode": 2,
1640+
})
1641+
executor = _make_mock_executor(stdout=envelope)
1642+
toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
1643+
tool = skill_toolset.RunSkillScriptTool(toolset)
1644+
ctx = _make_tool_context_with_agent()
1645+
result = await tool.run_async(
1646+
args={"skill_name": "skill1", "file_path": "setup.sh"},
1647+
tool_context=ctx,
1648+
)
1649+
assert result["status"] == "error"
1650+
assert result["stderr"] == "some error occurred\nExit code 2"
1651+
1652+
16301653
@pytest.mark.asyncio
16311654
async def test_shell_json_envelope_with_stderr(mock_skill1):
16321655
"""Shell envelope with both stdout and stderr reports warning."""
1633-
import json
16341656

16351657
envelope = json.dumps({
16361658
"__shell_result__": True,
@@ -1654,13 +1676,13 @@ async def test_shell_json_envelope_with_stderr(mock_skill1):
16541676
@pytest.mark.asyncio
16551677
async def test_shell_json_envelope_timeout(mock_skill1):
16561678
"""Shell envelope from TimeoutExpired reports error status."""
1657-
import json
16581679

16591680
envelope = json.dumps({
16601681
"__shell_result__": True,
16611682
"stdout": "partial output\n",
16621683
"stderr": "Timed out after 300s",
16631684
"returncode": -1,
1685+
"timeout": True,
16641686
})
16651687
executor = _make_mock_executor(stdout=envelope)
16661688
toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
@@ -1673,6 +1695,7 @@ async def test_shell_json_envelope_timeout(mock_skill1):
16731695
assert result["status"] == "error"
16741696
assert result["stdout"] == "partial output\n"
16751697
assert "Timed out" in result["stderr"]
1698+
assert "Exit code" not in result["stderr"]
16761699

16771700

16781701
@pytest.mark.asyncio

0 commit comments

Comments
 (0)