Skip to content

skills: inaccessible directories silently bypass on_error on Python 3.14/3.15 #7749

Description

@hawktang

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 PermissionError from the metadata/stat operation used by Path.is_dir() can propagate.
  • Python 3.14 and 3.15: Path.is_dir() returns False for operating-system OSError failures, including PermissionError.
  • 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

  1. Use Python 3.11, 3.12, 3.13, 3.14, or 3.15.
  2. Run the standalone reproducer below. It fetches the immutable ADK 2.13.0 source and executes the unmodified _list_skills_in_dir function in isolation, with postponed annotations to avoid unrelated dependency imports.
  3. The OS metadata boundary is controlled to raise PermissionError for one enumerated child. No real files or permissions are changed.
  4. Compare the result, exception, and on_error calls 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

  1. changed the title [-]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[/+] on Oct 26, 2026
  2. its-amann commented on Oct 26, 2026

    @its-amann

    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.

  3. its-amann commented on Oct 27, 2026

    @its-amann

    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.

  4. inamdarmihir commented on Oct 27, 2026

    @inamdarmihir

    Opened #7760. _list_skills_in_dir now uses stat() + S_ISDIR instead of Path.is_dir(), and routes any OSError through the same on_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_error was never called and nothing was returned; after, on_error is 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.

  5. added 2 commits that reference this issue on Oct 27, 2026
    d6c7a9f
    cd3fada
  6. its-amann commented on Oct 27, 2026

    @its-amann

    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.

  7. added 2 commits that reference this issue on Oct 27, 2026
    0762ef0
    559e1e3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

tools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions