fix(uploads): resolve conversion settings in the parser worker (#7642)

This commit is contained in:
Winoren 2026-10-30 08:50:37 +08:00 committed by GitHub
parent 9e6ce2f98e
commit c50cdc5161
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 72 additions and 5 deletions

View File

@ -2462,6 +2462,8 @@ Unsent Web UI composer drafts survive page reloads and switching between convers
If an attachment upload reports `success: false`, the Web UI stops the send even when the HTTP response is 200. The upload error is shown and the composer keeps its draft and attachments for correction or retry. Files already accepted by a partially successful upload remain on the server; this does not roll them back.
Document conversion reads the live PDF-converter setting and parses documents in a background worker, keeping configuration-file checks off the Gateway event loop.
The Web UI composer supports `@` references at the cursor or through the `@` button. Search enabled skills, documents in the current project, and other conversations in a grouped picker; selected references appear inline alongside the draft text and can be removed with normal editing. Multiple skills can be selected in one message (up to 16 unique skills); selecting a checked skill again removes it, with each selection checked against the current user registry and agent allowlist. Project files are attached only after ingestion succeeds; conversation references require the `read_conversation` capability and retain its per-run limit. If capability discovery fails, saved conversation references remain intact and sending waits for a successful retry. Polishing preserves complete reference labels when instructions are reordered. The file group also offers local uploads. `/goal`, `/compact`, and legacy `/skill-name` input remain supported.
After picking an inline reference, typing continues immediately after it. Moving the cursor keeps the position you chose, including when editing text beside a reference.

View File

@ -136,6 +136,13 @@ pins offload, executor isolation, request context, and failure ordering.
Tool and agent assembly re-enters `get_available_tools()` and may block on MCP discovery, so the four async assembly entry points — `run_agent`'s `agent_factory` call, `task_tool`, durable batch `_execute_item`, and `abuild_checkpoint_state_accessor` — dispatch through `deerflow.utils.assembly_io.run_assembly`, a dedicated ContextVar-preserving bounded executor (`DEER_FLOW_ASSEMBLY_WORKERS`, default 8) rather than the loop's default executor; a hung MCP server therefore parks an assembly worker instead of queueing unrelated default-executor work, and the pool logs a warning when pending assemblies exceed the worker count. `tests/blocking_io/test_tool_assembly_offloop.py` pins all four offloads plus the ContextVar propagation.
### Document Conversion
`file_conversion.convert_file_to_markdown` resolves the live PDF converter in
its drained parsing worker. Config cache hits still stat/hash files; keep them
off-loop with parsing. Cold/cache-hit coverage uses real temporary configs in
`tests/blocking_io/test_file_conversion_offloop.py`.
### Uploaded Document Summaries
Outlines skip root-level HTML comment blocks (`<!--` with up to three leading

View File

@ -8,9 +8,9 @@ PDF conversion strategy (auto mode):
total when page count is unavailable), treat as image-based and fall back to MarkItDown.
3. If pymupdf4llm is not installed, use MarkItDown directly (existing behaviour).
All document parsing runs in a thread pool via asyncio.to_thread() so compressed
Office files and small PDFs cannot block the serving event loop. Source byte
size does not bound parsing time.
Configuration resolution and all document parsing run in a thread pool via
asyncio.to_thread() so config reads, compressed Office files and small PDFs cannot
block the serving event loop. Source byte size does not bound parsing time.
No FastAPI or HTTP dependencies — pure utility functions.
"""
@ -154,12 +154,17 @@ async def convert_file_to_markdown(file_path: Path, output_path: Path | None = N
Returns:
Path to the generated .md file, or None if conversion failed.
"""
def convert_source() -> str:
return _do_convert(file_path, _get_pdf_converter())
try:
pdf_converter = _get_pdf_converter()
# Config resolution also stats and hashes files, including cache hits.
# Keep it in the same drained worker as parsing.
# Compressed size cannot predict parsing cost (e.g. a small XLSX can
# contain many cells). Always offload, and drain across cancellation so
# the caller retains its source until the converter finishes reading.
text = await await_drained(asyncio.to_thread(_do_convert, file_path, pdf_converter))
text = await await_drained(asyncio.to_thread(convert_source))
md_path = output_path if output_path is not None else file_path.with_suffix(".md")
# Drain the write-back across caller cancellation: run_file_io awaits

View File

@ -93,3 +93,56 @@ async def test_cancelled_conversion_cleanup_failure_preserves_cancellation(
finish.set()
assert any("partial conversion output" in record.getMessage() for record in caplog.records), "cleanup failure should be logged inside the handler, not escaped"
@pytest.mark.parametrize("pdf_converter", ["markitdown", "pymupdf4llm"])
@pytest.mark.parametrize("warm_cache", [False, True], ids=["cold_config", "cached_config"])
async def test_conversion_resolves_real_config_in_the_parser_worker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pdf_converter: str, warm_cache: bool) -> None:
from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config
from deerflow.config.extensions_config import reset_extensions_config
from deerflow.utils import file_conversion
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
source = tmp_path / "document.pdf"
def seed_files() -> None:
config_path.write_text(f"sandbox:\n use: deerflow.sandbox.local:LocalSandboxProvider\nuploads:\n pdf_converter: {pdf_converter}\n", encoding="utf-8")
extensions_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
source.write_text("synthetic source", encoding="utf-8")
await asyncio.to_thread(seed_files)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
reset_extensions_config()
config_threads: list[int] = []
parser_threads: list[int] = []
selected: list[str] = []
loop_thread = threading.get_ident()
def load_config() -> AppConfig:
config_threads.append(threading.get_ident())
return get_app_config()
def parse_document(path: Path, converter: str) -> str:
parser_threads.append(threading.get_ident())
selected.append(converter)
return path.read_text(encoding="utf-8") + " converted"
monkeypatch.setattr(file_conversion, "get_app_config", load_config)
monkeypatch.setattr(file_conversion, "_do_convert", parse_document)
try:
if warm_cache:
await asyncio.to_thread(get_app_config)
output = await file_conversion.convert_file_to_markdown(source)
finally:
reset_app_config()
reset_extensions_config()
assert config_threads == parser_threads
assert len(config_threads) == 1 and config_threads[0] != loop_thread
assert selected == [pdf_converter]
assert output is not None
assert output == source.with_suffix(".md")
assert output.read_text(encoding="utf-8") == "synthetic source converted"