Skip to content

[Bug]: KeyError: 'type' in FileHelper when parameter schema omits top-level "type" (anyOf / $ref / etc.) #3145

@sukhmani-ivc

Description

@sukhmani-ivc

SDK Language

Python SDK (composio package)

SDK Version

composio 0.9.1 composio_core 0.7.21 composio-client 1.11.0 composio-openai-agents 0.8.0

Runtime Environment

Python: 3.12.x (e.g. 3.12 from project venv) OS: macOS (Darwin 25.x; arm64 or x86_64) Integration: OpenAI Agents SDK via OpenAIAgentsProvider (composio_openai_agents)

Environment

Local Development

Describe the Bug

In composio/core/models/_files.py, FileHelper._substitute_file_uploads_recursively and _substitute_file_downloads_recursively assume every property in schema["properties"] has a JSON Schema object with a top-level "type" key.

Many real tool definitions instead use:

  • anyOf / oneOf (no top-level "type")
  • $ref (no top-level "type" on the property node)

When the runtime passes a dict for such a parameter (or when post-processing responses with nested dicts under the same shape), the code does:

params[_param]["type"] == "object"

which raises KeyError: 'type'.

That exception is often surfaced through composio_openai_agents/provider.py as a JSON error string like "error": "'type'", so agents see a cryptic failure even when the remote action may have succeeded.

Observed schema examples that trigger the issue:

  • Output data field: {'$ref': '#/$defs/SomeResponse', 'title': 'Data', 'description': '...'} (no "type")
  • Input params using anyOf for nullable or union types (no top-level "type")

The fix is to use safe access (e.g. params[_param].get("type") == "object") and/or resolve $ref / branch on anyOf before assuming "type" exists.

Steps to Reproduce

  1. Install the versions above (composio, composio_core, composio-openai-agents).
  2. Use OpenAI Agents with Composio tools whose schemas include properties without a top-level "type" (common for $ref or anyOf).
  3. Execute a tool where:
    • Upload path: the model supplies a nested dict for a parameter whose schema has no "type" (hits _substitute_file_uploads_recursively), or
    • Download path: the response includes nested dict data under a property whose schema has no "type" (hits _substitute_file_downloads_recursively).
  4. Observe KeyError: 'type' in the stack (or {"successful": false, "error": "'type'"} from the provider wrapper).

Minimal Reproducible Example

Example A — upload path (substitute_file_uploads → KeyError: 'type')
A property schema uses anyOf only (no top-level "type"). The request passes a dict for that parameter, so the code hits params[_param]["type"] and raises.

"""MRE: KeyError('type') in FileHelper.substitute_file_uploads (real SDK)."""
import os
# Must be set before importing composio if default ~/.composio is not writable
os.environ.setdefault("COMPOSIO_CACHE_DIR", "/tmp/composio_cache_mre")
os.makedirs(os.environ["COMPOSIO_CACHE_DIR"], exist_ok=True)
from types import SimpleNamespace
from composio.core.models._files import FileHelper
# Tool only needs .slug, .toolkit.slug, .input_parameters for this code path
tool = SimpleNamespace(
    slug="FAKE_TOOL",
    toolkit=SimpleNamespace(slug="fake"),
    input_parameters={
        "type": "object",
        "properties": {
            "metadata": {
                "anyOf": [
                    {"type": "object", "properties": {"x": {"type": "string"}}},
                    {"type": "null"},
                ],
                "title": "Metadata",
            },
        },
        "required": [],
    },
)
class DummyHttpClient:
    """Not used on this path (no file_uploadable / no from_path)."""
    pass
fh = FileHelper(client=DummyHttpClient())  # type: ignore[arg-type]
request = {"metadata": {"x": "nested dict triggers object branch"}}
# Raises: KeyError: 'type'
fh.substitute_file_uploads(tool, request)

Expected: KeyError: 'type' at _substitute_file_uploads_recursively where it does params[_param]["type"] == "object".

Example B — download path (substitute_file_downloads → KeyError: 'type')
Output schema uses $ref (no top-level "type" on the property). The response has a dict under data, so the same pattern fails on the download path.

"""MRE: KeyError('type') in FileHelper.substitute_file_downloads (real SDK)."""
import os
os.environ.setdefault("COMPOSIO_CACHE_DIR", "/tmp/composio_cache_mre")
os.makedirs(os.environ["COMPOSIO_CACHE_DIR"], exist_ok=True)
from types import SimpleNamespace
from composio.core.models._files import FileHelper
tool = SimpleNamespace(
    slug="FAKE_TOOL",
    toolkit=SimpleNamespace(slug="fake"),
    output_parameters={
        "type": "object",
        "properties": {
            "data": {
                "$ref": "#/$defs/SomeResponse",
                "title": "Data",
                "description": "Data from the action execution",
            },
        },
    },
)
class DummyHttpClient:
    pass
fh = FileHelper(client=DummyHttpClient())  # type: ignore[arg-type]
response = {"successful": True, "data": {"any": "nested dict"}}
# Raises: KeyError: 'type'
fh.substitute_file_downloads(tool, response)

Expected: KeyError: 'type' at _substitute_file_downloads_recursively on params[_param]["type"] == "object".

Tool is composio_client.types.tool_list_response.Item, a Pydantic model with many required fields (deprecated, etc.). For an MRE, SimpleNamespace is enough: FileHelper only uses tool.slug, tool.toolkit.slug, and tool.input_parameters / tool.output_parameters on these paths :)

Error Output / Stack Trace

Exception:

KeyError: 'type'

Typical stack (upload path, line numbers from composio_core 0.7.21-style layout):

Traceback (most recent call last):
  File ".../composio_openai_agents/provider.py", line ~86, in execute_tool_wrapper
    await asyncio.to_thread(execute_tool, slug=..., arguments=...)
  File ".../composio/.../tools.py", line ..., in execute
    ...
  File ".../composio/core/models/_files.py", line 235, in _substitute_file_uploads_recursively
    if isinstance(request[_param], dict) and params[_param]["type"] == "object":
                                               ~~~~~~~~~~~~~^^^^^^^^
KeyError: 'type'
Typical stack (download path):

  File ".../composio/core/models/_files.py", line 308, in _substitute_file_downloads_recursively
    if isinstance(request[_param], dict) and params[_param]["type"] == "object":
                                               ~~~~~~~~~~~~~^^^^^^^^
KeyError: 'type'
What the agent sees when the provider catches the exception:

{"successful": false, "error": "'type'", "data": null}

Reproducibility

  • Always reproducible
  • Intermittent / Sometimes
  • Happened once, can’t reproduce

Additional Context or Screenshots

Suggested fix (for maintainers)
Replace direct params[_param]["type"] with params[_param].get("type") and only recurse into nested object substitution when that value is "object", or extend handling for $ref / anyOf so file substitution logic does not assume a single top-level "type" key.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions