Lorenze/fix/file input not working reliably (#6020)

* fix filesystem

* Refine commit message formatting

* fix for async kickoffs

* added suggestion
This commit is contained in:
Lorenze Jay
2026-06-02 17:14:51 -07:00
committed by GitHub
parent b047c96756
commit 770d1b284f
10 changed files with 403 additions and 19 deletions

BIN
2605.26112v1.pdf Normal file

Binary file not shown.

View File

@@ -11,7 +11,10 @@ from crewai_files.formatting.anthropic import AnthropicFormatter
from crewai_files.formatting.bedrock import BedrockFormatter
from crewai_files.formatting.gemini import GeminiFormatter
from crewai_files.formatting.openai import OpenAIFormatter, OpenAIResponsesFormatter
from crewai_files.processing.constraints import get_constraints_for_provider
from crewai_files.processing.constraints import (
get_constraints_for_provider,
uses_openai_responses_api,
)
from crewai_files.processing.processor import FileProcessor
from crewai_files.resolution.resolver import FileResolver, FileResolverConfig
from crewai_files.uploaders.factory import ProviderType
@@ -120,9 +123,11 @@ def format_multimodal_content(
if not files:
return content_blocks
constraints_key: str = provider_type
if api == "responses" and "openai" in provider_type.lower():
constraints_key = "openai_responses"
constraints_key = (
"openai_responses"
if uses_openai_responses_api(provider_type, api)
else provider_type
)
processor = FileProcessor(constraints=constraints_key)
processed_files = processor.process_files(files)
@@ -184,9 +189,11 @@ async def aformat_multimodal_content(
if not files:
return content_blocks
constraints_key: str = provider_type
if api == "responses" and "openai" in provider_type.lower():
constraints_key = "openai_responses"
constraints_key = (
"openai_responses"
if uses_openai_responses_api(provider_type, api)
else provider_type
)
processor = FileProcessor(constraints=constraints_key)
processed_files = await processor.aprocess_files(files)

View File

@@ -346,6 +346,20 @@ def get_constraints_for_provider(
return None
def uses_openai_responses_api(provider: str, api: str | None = None) -> bool:
"""Return whether provider/API should use OpenAI Responses file support."""
if api != "responses":
return False
provider_lower = provider.lower()
return (
"openai" in provider_lower
or provider_lower == "gpt"
or provider_lower.startswith("gpt-")
or "/gpt-" in provider_lower
)
def get_supported_content_types(provider: str, api: str | None = None) -> list[str]:
"""Get supported MIME type prefixes for a provider.
@@ -356,9 +370,9 @@ def get_supported_content_types(provider: str, api: str | None = None) -> list[s
Returns:
List of supported MIME type prefixes (e.g., ["image/", "application/pdf"]).
"""
lookup_key = provider
if api == "responses" and "openai" in provider.lower():
lookup_key = "openai_responses"
lookup_key = (
"openai_responses" if uses_openai_responses_api(provider, api) else provider
)
constraints = get_constraints_for_provider(lookup_key)
if not constraints:

View File

@@ -11,6 +11,7 @@ from crewai_files.processing.constraints import (
ProviderConstraints,
VideoConstraints,
get_constraints_for_provider,
get_supported_content_types,
)
import pytest
@@ -70,6 +71,13 @@ class TestPDFConstraints:
assert constraints.max_size_bytes == 1000
assert constraints.max_pages is None
@pytest.mark.parametrize("provider", ["openai", "gpt", "gpt-4o-mini"])
def test_openai_responses_supports_pdf_for_gpt_aliases(self, provider):
"""OpenAI Responses PDF support applies to concrete GPT model names."""
supported_types = get_supported_content_types(provider, api="responses")
assert "application/pdf" in supported_types
class TestAudioConstraints:
"""Tests for AudioConstraints dataclass."""

View File

@@ -93,6 +93,7 @@ from crewai.utilities.agent_utils import (
track_delegation_if_needed,
)
from crewai.utilities.constants import TRAINING_DATA_FILE
from crewai.utilities.file_store import aget_all_files, get_all_files
from crewai.utilities.i18n import I18N_DEFAULT
from crewai.utilities.planning_types import (
PlanStep,
@@ -2771,7 +2772,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
self._inject_files_from_inputs(inputs)
await self._ainject_files_from_inputs(inputs)
self.state.ask_for_human_input = bool(
inputs.get("ask_for_human_input", False)
@@ -2982,12 +2983,42 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
training_handler.save(training_data)
def _inject_files_from_inputs(self, inputs: dict[str, Any]) -> None:
"""Inject files from inputs into the last user message.
"""Inject files into the last user message.
Args:
inputs: Input dictionary that may contain a 'files' key.
"""
files = inputs.get("files")
files: dict[str, Any] = {}
if self.crew and self.task:
stored_files = get_all_files(self.crew.id, self.task.id)
if stored_files:
files.update(stored_files)
if inputs.get("files"):
files.update(inputs["files"])
if not files:
return
for i in range(len(self.state.messages) - 1, -1, -1):
msg = self.state.messages[i]
if msg.get("role") == "user":
msg["files"] = files
break
async def _ainject_files_from_inputs(self, inputs: dict[str, Any]) -> None:
"""Async inject files into the last user message."""
files: dict[str, Any] = {}
if self.crew and self.task:
stored_files = await aget_all_files(self.crew.id, self.task.id)
if stored_files:
files.update(stored_files)
if inputs.get("files"):
files.update(inputs["files"])
if not files:
return

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import base64
from io import BytesIO
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field, PrivateAttr
@@ -64,6 +65,9 @@ class ReadFileTool(BaseTool):
content_type = file_input.content_type
filename = file_input.filename or file_name
if content_type == "application/pdf":
return self._read_pdf_text(content, filename)
text_types = (
"text/",
"application/json",
@@ -76,3 +80,22 @@ class ReadFileTool(BaseTool):
encoded = base64.b64encode(content).decode("ascii")
return f"[Binary file: {filename} ({content_type})]\nBase64: {encoded}"
def _read_pdf_text(self, content: bytes, filename: str) -> str:
"""Extract text from a PDF instead of returning base64."""
try:
from pypdf import PdfReader
except ImportError:
encoded = base64.b64encode(content).decode("ascii")
return f"[Binary file: {filename} (application/pdf)]\nBase64: {encoded}"
try:
reader = PdfReader(BytesIO(content))
page_text = [text for page in reader.pages if (text := page.extract_text())]
except Exception as exc:
return f"Unable to extract text from PDF '{filename}': {exc}"
if not page_text:
return f"[PDF file with no extractable text: {filename}]"
return "\n\n".join(page_text)

View File

@@ -7,9 +7,11 @@ flow methods, routing logic, and error handling.
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import time
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from uuid import uuid4
import pytest
from pydantic import BaseModel
@@ -64,6 +66,8 @@ from crewai.events.types.tool_usage_events import (
from crewai.tools.tool_types import ToolResult
from crewai.utilities.step_execution_context import StepExecutionContext
from crewai.utilities.planning_types import TodoItem
from crewai.utilities.file_store import clear_files, clear_task_files, store_files
from crewai_files import TextFile
class TestAgentExecutorState:
"""Test AgentExecutorState Pydantic model."""
@@ -112,6 +116,58 @@ class TestAgentExecutor:
class StructuredResult(BaseModel):
value: str
def test_inject_files_from_crew_task_store(self):
"""Crew-level input_files should attach to the LLM user message."""
crew_id = uuid4()
task_id = uuid4()
stored_file = TextFile(source=b"stored content")
executor = _build_executor(
crew=SimpleNamespace(id=crew_id),
task=SimpleNamespace(id=task_id),
)
executor.state.messages = [{"role": "user", "content": "Analyze this file"}]
try:
store_files(crew_id, {"document": stored_file})
executor._inject_files_from_inputs({})
finally:
clear_files(crew_id)
clear_task_files(task_id)
assert executor.state.messages[0]["files"] == {"document": stored_file}
@pytest.mark.asyncio
async def test_ainject_files_from_crew_task_store_uses_async_store(self):
"""Async file injection should not call the sync file store helper."""
crew_id = uuid4()
task_id = uuid4()
stored_file = TextFile(source=b"stored content")
local_file = TextFile(source=b"local content")
inputs = {"files": {"local": local_file}}
executor = _build_executor(
crew=SimpleNamespace(id=crew_id),
task=SimpleNamespace(id=task_id),
)
executor.state.messages = [{"role": "user", "content": "Analyze this file"}]
with (
patch(
"crewai.experimental.agent_executor.aget_all_files",
new=AsyncMock(return_value={"document": stored_file}),
) as async_get_files,
patch(
"crewai.experimental.agent_executor.get_all_files",
side_effect=AssertionError("sync file store should not be called"),
),
):
await executor._ainject_files_from_inputs(inputs)
async_get_files.assert_awaited_once_with(crew_id, task_id)
assert executor.state.messages[0]["files"] == {
"document": stored_file,
"local": local_file,
}
@pytest.fixture
def mock_dependencies(self):
"""Create mock dependencies for executor."""

View File

@@ -108,6 +108,16 @@ class TestLiteLLMMultimodal:
assert result == []
def test_format_responses_pdf_with_concrete_gpt_model(self) -> None:
"""Test OpenAI Responses PDF support with an inferred GPT provider."""
files = {"doc": PDFFile(source=MINIMAL_PDF)}
result = format_multimodal_content(files, "gpt-4o-mini", api="responses")
assert len(result) == 1
assert result[0]["type"] == "input_file"
assert result[0]["file_data"].startswith("data:application/pdf;base64,")
@pytest.mark.skipif(not HAS_ANTHROPIC, reason="Anthropic SDK not installed")
class TestAnthropicMultimodal:
@@ -370,4 +380,4 @@ class TestMultipleFilesFormatting:
result = format_multimodal_content({}, llm.model)
assert result == []
assert result == []

View File

@@ -1,11 +1,20 @@
"""Unit tests for ReadFileTool."""
import base64
from pathlib import Path
from crewai.tools.agent_tools.read_file_tool import ReadFileTool
from crewai_files import ImageFile, PDFFile, TextFile
TEST_FIXTURES_DIR = (
Path(__file__).parent.parent.parent.parent.parent
/ "crewai-files"
/ "tests"
/ "fixtures"
)
class TestReadFileTool:
"""Tests for ReadFileTool."""
@@ -72,15 +81,15 @@ class TestReadFileTool:
decoded = base64.b64decode(b64_part)
assert decoded == png_bytes
def test_run_pdf_file_returns_base64(self) -> None:
"""Test reading a PDF file returns base64 encoded content."""
pdf_bytes = b"%PDF-1.4 some content here"
def test_run_pdf_file_returns_extracted_text(self) -> None:
"""Test reading a PDF file returns extracted text instead of base64."""
pdf_bytes = (TEST_FIXTURES_DIR / "agents.pdf").read_bytes()
self.tool.set_files({"doc.pdf": PDFFile(source=pdf_bytes)})
result = self.tool._run(file_name="doc.pdf")
assert "[Binary file:" in result
assert "application/pdf" in result
assert "Base64:" not in result
assert "agents" in result.lower()
def test_set_files_none(self) -> None:
"""Test setting files to None."""

View File

@@ -0,0 +1,226 @@
# ruff: noqa: T201
"""Manual runner for AGE-90 PDF input handling.
Usage examples:
uv run python scripts/age90_file_input_runner.py
uv run python scripts/age90_file_input_runner.py --mode fallback
uv run python scripts/age90_file_input_runner.py --mode payload --pdf ./sample_story.pdf
uv run python scripts/age90_file_input_runner.py --mode kickoff --pdf ./sample_story.pdf
"""
from __future__ import annotations
import argparse
from collections.abc import Mapping, Sequence
from contextlib import nullcontext
import os
from pathlib import Path
from typing import Any
from unittest.mock import patch
from crewai_files import PDFFile, format_multimodal_content, get_supported_content_types
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PDF = ROOT / "lib" / "crewai-files" / "tests" / "fixtures" / "agents.pdf"
def _content_summary(block: dict[str, Any]) -> dict[str, str]:
"""Return a compact, non-base64 summary of a content block."""
summary: dict[str, str] = {"type": str(block.get("type"))}
for key in ("file_id", "file_url", "filename", "image_url"):
if key in block:
value = str(block[key])
summary[key] = value[:100] + ("..." if len(value) > 100 else "")
if "file_data" in block:
value = str(block["file_data"])
summary["file_data"] = value[:80] + f"... ({len(value)} chars)"
return summary
def _sanitize_payload(value: Any) -> Any:
"""Shorten large fields before printing API payloads."""
if isinstance(value, Mapping):
sanitized: dict[str, Any] = {}
for key, item in value.items():
if key == "file_data" and isinstance(item, str):
sanitized[key] = item[:100] + f"... ({len(item)} chars)"
else:
sanitized[str(key)] = _sanitize_payload(item)
return sanitized
if isinstance(value, Sequence) and not isinstance(value, str | bytes):
return [_sanitize_payload(item) for item in value]
return value
def inspect_native_path(pdf_path: Path, provider: str, api: str | None) -> None:
"""Show whether the PDF is treated as a native multimodal input."""
pdf = PDFFile(source=str(pdf_path))
supported_types = get_supported_content_types(provider, api=api)
blocks = format_multimodal_content(
{"document": pdf},
provider=provider,
api=api,
text="Summarize this PDF.",
)
print("\n== Native File Formatting ==")
print(f"PDF: {pdf_path}")
print(f"Provider/API: {provider} / {api or 'default'}")
print(f"Supported content types: {supported_types}")
print(f"Content block count: {len(blocks)}")
for index, block in enumerate(blocks, start=1):
print(f" {index}. {_content_summary(block)}")
has_pdf_block = any(block.get("type") == "input_file" for block in blocks)
print(f"PDF native input_file block: {'YES' if has_pdf_block else 'NO'}")
def inspect_fallback_tool(pdf_path: Path) -> None:
"""Show what read_file returns if a PDF falls back to the tool path."""
from crewai.tools.agent_tools.read_file_tool import ReadFileTool
tool = ReadFileTool()
tool.set_files({"document": PDFFile(source=str(pdf_path))})
result = tool._run("document")
print("\n== read_file Fallback ==")
print(f"Returned {len(result)} chars")
print(f"Contains Base64 marker: {'YES' if 'Base64:' in result else 'NO'}")
print("\nPreview:")
print(result[:1200])
if len(result) > 1200:
print("...")
def run_crew_kickoff(
pdf_path: Path,
model: str,
api: str | None,
prompt: str,
*,
payload_only: bool = False,
) -> None:
"""Run a real Crew kickoff against the supplied model."""
from crewai import LLM, Agent, Crew, Task
if model.startswith("openai/") and not os.getenv("OPENAI_API_KEY") and not payload_only:
raise SystemExit(
"OPENAI_API_KEY is not set. Export it before running --mode kickoff."
)
kwargs: dict[str, Any] = {"model": model, "temperature": 0}
if api:
kwargs["api"] = api
llm = LLM(**kwargs)
agent = Agent(
role="PDF Analyst",
goal="Read the provided PDF and answer accurately from its contents",
backstory="You inspect uploaded files carefully and avoid guessing.",
llm=llm,
verbose=True,
)
task = Task(
description=prompt,
expected_output="A concise answer grounded in the uploaded PDF.",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task], verbose=True)
print("\n== Crew Kickoff ==")
print(f"Model/API: {model} / {api or 'default'}")
print(f"PDF: {pdf_path}")
context = nullcontext()
if payload_only:
from crewai.llms.providers.openai.completion import OpenAICompletion
def print_payload_and_stop(
self: OpenAICompletion,
params: dict[str, Any],
*_args: Any,
**_kwargs: Any,
) -> str:
print("\n== Sanitized Responses Payload ==")
print(_sanitize_payload(params))
return "Payload debug complete."
context = patch.object(
OpenAICompletion,
"_handle_responses",
print_payload_and_stop,
)
with context:
result = crew.kickoff(input_files={"document": PDFFile(source=str(pdf_path))})
print("\n== Final Output ==")
print(result.raw)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--mode",
choices=("inspect", "fallback", "payload", "kickoff", "all"),
default="inspect",
help="What to run. 'inspect', 'fallback', and 'payload' do not call an LLM.",
)
parser.add_argument(
"--pdf",
type=Path,
default=DEFAULT_PDF,
help="PDF file to test.",
)
parser.add_argument(
"--provider",
default="gpt-4o-mini",
help="Provider/model string for file formatting inspection.",
)
parser.add_argument(
"--model",
default="openai/gpt-4o-mini",
help="CrewAI model for real kickoff mode.",
)
parser.add_argument(
"--api",
default="responses",
help="API variant. Use '' to omit.",
)
parser.add_argument(
"--prompt",
default="Summarize the uploaded PDF in 3 bullet points. Do not guess.",
help="Task prompt for kickoff mode.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
pdf_path = args.pdf.expanduser().resolve()
api = args.api or None
if not pdf_path.exists():
raise SystemExit(f"PDF not found: {pdf_path}")
if args.mode in ("inspect", "all"):
inspect_native_path(pdf_path, args.provider, api)
if args.mode in ("fallback", "all"):
inspect_fallback_tool(pdf_path)
if args.mode == "payload":
run_crew_kickoff(pdf_path, args.model, api, args.prompt, payload_only=True)
if args.mode in ("kickoff", "all"):
run_crew_kickoff(
pdf_path,
args.model,
api,
args.prompt,
payload_only=args.mode == "all",
)
if __name__ == "__main__":
main()