Repository navigation
skills: inaccessible directories silently bypass on_error on Python 3.14/3.15 #7749
Description
Describe the bug
In ADK 2.13.0, local skill discovery can silently skip an inaccessible skill directory on Python 3.14 and 3.15 without invoking on_error. The caller receives neither the skill nor its name/error.
The root cause is a documented Python standard-library behavior change, combined with the location of ADK's directory check:
- Python 3.11–3.13: a
PermissionErrorfrom the metadata/stat operation used byPath.is_dir()can propagate. - Python 3.14 and 3.15:
Path.is_dir()returnsFalsefor operating-systemOSErrorfailures, includingPermissionError. - ADK declares Python >=3.11, so the same skill-discovery path spans both behaviors.
In _list_skills_in_dir() at the 2.13.0 release commit, the preliminary check occurs before the error-handling block:
for skill_dir in sorted(skills_base_path.iterdir()):
if not skill_dir.is_dir():
continue
skill_id = skill_dir.name
try:
...On Python 3.14 and 3.15, a metadata permission failure therefore takes the ordinary continue path. The documented on_error(skill_id, error) hook never sees it. This also prevents applications from reserving or reporting a discovered-but-unavailable skill name while skipping its content.
This report concerns a failure while checking directory metadata, not every directory whose contents cannot be listed. Lack of directory read permission alone does not necessarily make is_dir() fail.
Steps to reproduce
- Use Python 3.11, 3.12, 3.13, 3.14, or 3.15.
- Run the standalone reproducer below. It fetches the immutable ADK 2.13.0 source and executes the unmodified
_list_skills_in_dirfunction in isolation, with postponed annotations to avoid unrelated dependency imports. - The OS metadata boundary is controlled to raise
PermissionErrorfor one enumerated child. No real files or permissions are changed. - Compare the result, exception, and
on_errorcalls across Python versions.
Expected behavior
A failure to inspect an enumerated child should remain observable consistently across supported Python versions:
- With
on_error, call it with the child's name and the original exception. A callback that returns may skip the skill; a callback that raises may abort. - Without
on_error, report the inaccessible child rather than silently treating it as an ordinary non-directory. - The invalid/unreadable skill need not appear in the returned frontmatter dictionary; its name can be communicated through the error callback.
Observed behavior
The following results were verified by replaying the same unmodified ADK 2.13.0 function against the actual pathlib implementations:
| Python | Directory metadata failure | ADK result | on_error calls |
|---|---|---|---|
| 3.11.18 | PermissionError |
Exception propagates | 0 |
| 3.12.16 | PermissionError |
Exception propagates | 0 |
| 3.13.18 | PermissionError |
Exception propagates | 0 |
| 3.14.10 | PermissionError |
Returns {} silently |
0 |
| 3.15.1 | PermissionError |
Returns {} silently |
0 |
The older behavior was not already correct callback handling: the exception also bypassed on_error, because the probe is before the try. The behavior change introduced in Python 3.14 makes that failure silent by returning False; the same behavior was also reproduced on Python 3.15.1.
Environment details
- ADK version: 2.13.0, also checked against its published PyPI wheel.
- Release commit:
d68ca857d5e3bab8e968d4e5f98ef028eb37dc87. - OS: macOS, Apple Silicon.
- Python versions: 3.11.18, 3.12.16, 3.13.18, 3.14.10, 3.15.1.
- Model / LiteLLM: N/A; this is filesystem skill discovery, with no model calls.
- Frequency: 100% under the controlled metadata failure.
- Verification scope: isolated source replay and wheel/source inspection, not a full agent end-to-end test.
Minimal reproduction code
Standard library only; no ADK installation or filesystem permission changes are needed:
import ast,errno,json,logging,os,pathlib,stat,sys,urllib.request
from unittest.mock import patch
url='https://raw.githubusercontent.com/google/adk-python/d68ca857d5e3bab8e968d4e5f98ef028eb37dc87/src/google/adk/skills/_utils.py'
with urllib.request.urlopen(url,timeout=20) as response: source=response.read().decode()
function=next(n for n in ast.parse(source).body if isinstance(n,ast.FunctionDef) and n.name=='_list_skills_in_dir')
module=ast.Module(body=[ast.ImportFrom(module='__future__',names=[ast.alias(name='annotations')],level=0),function],type_ignores=[])
namespace={'pathlib':pathlib,'logging':logging,'ValidationError':ValueError}
exec(compile(ast.fix_missing_locations(module),'<ADK-2.13.0-isolated-source>','exec'),namespace)
root=pathlib.Path('/__adk_readonly_repro__')
blocked=root/'blocked-skill'
errors=[]
stat_failures=[]
def fake_stat(path,*args,**kwargs):
if os.fspath(path)==str(root): return os.stat_result((stat.S_IFDIR|0o755,0,0,1,0,0,0,0,0,0))
stat_failures.append(os.fspath(path))
raise PermissionError(errno.EACCES,'controlled metadata permission failure',os.fspath(path))
result=None
exception=None
with patch.object(pathlib.Path,'resolve',lambda self,*a,**kw:self),patch.object(pathlib.Path,'iterdir',lambda self:iter([blocked])),patch('os.stat',side_effect=fake_stat):
try: result=namespace['_list_skills_in_dir'](root,on_error=lambda name,error:errors.append(name))
except OSError as error: exception=type(error).__name__
assert stat_failures==[str(blocked)]
assert errors==[]
assert (exception=='PermissionError' and result is None) if sys.version_info<(3,14) else (exception is None and result=={})
print(json.dumps({'python':sys.version.split()[0],'adk':'2.13.0','result':result,'exception':exception,'on_error_calls':errors,'controlled_stat_failure':True}))Root cause and possible fix
The Python 3.14 pathlib documentation explicitly states:
The methods given above now return False instead of raising any OSError exception from the operating system. In previous versions, some kinds of OSError exception are raised, and others suppressed. ... Use stat() to retrieve the file status without suppressing exceptions.
A possible fix is to inspect directory type using Path.stat() plus stat.S_ISDIR(), within error handling that routes the child's name and original OSError through the same callback/logging policy. Simply adding try/except OSError around is_dir() will not address Python 3.14 or 3.15, because that method already suppresses the error.
A regression test should inject the failure at the stat/OS boundary. Mocking Path.is_dir() itself to raise would conceal the Python 3.14 behavior change.
Activity
[-]skills: Path.is_dir() silently bypasses on_error on Python 3.14 for inaccessible directories[/-][+]skills: inaccessible directories silently bypass on_error on Python 3.14/3.15[/+]its-amann commentedon Oct 26, 2026
Hi! I'd like to work on this. @wukath, could you assign it to me? I'll follow up with a PR that moves the directory check inside the error handling so on_error gets called, with tests for the Python 3.14 behaviour.
its-amann commentedon Oct 27, 2026
Hi @sanketpatil06, no worries if you're busy with other things right now. I had already looked into this one and have a fix ready that moves the directory check inside the error handling so on_error gets called, with tests for the Python 3.14 behaviour. If it helps, I'm happy to take it over, or we can open the PR together with you as co author. Just let me know what works for you.
inamdarmihir commentedon Oct 27, 2026
Opened #7760.
_list_skills_in_dirnow usesstat()+S_ISDIRinstead ofPath.is_dir(), and routes anyOSErrorthrough the sameon_error/warning path as other per-skill failures; missing or dangling entries are still skipped. Reproduced on Python 3.14.7 with a listable but non-searchable skills directory: before,on_errorwas never called and nothing was returned; after,on_erroris called for each inaccessible entry. @its-amann, I saw you mentioned a fix was ready; if you already have a PR open, feel free to point me at it.fix(skills): report inaccessible skill directories to on_error
fix(skills): report inaccessible skill directories to on_error
its-amann commentedon Oct 27, 2026
Thanks @inamdarmihir! I hadn't opened a PR yet, so please go ahead with #7760. Your approach matches what I had locally. In case it's useful, my version is here: its-amann@e3eca64
It also has tests for a real unsearchable directory, a dangling symlink, and the cases where no callback is set or the callback raises. Feel free to borrow any of those. I'm happy to review #7760 if that helps.
fix(skills): surface inaccessible skill dirs via on_error
fix(skills): surface inaccessible skill dirs via on_error