fix(document_query): isolate LiteParse parsing
Run LiteParse in a subprocess so native parser crashes cannot take down the Web UI process. Bound parser concurrency and LiteParse workers for multi-chat stability, seed Q&A context with leading document chunks for title/abstract grounding, and keep a small-document fallback when vector search returns no chunks.
Alessandro committed
May 29, 2026 at 15:51 UTC
b2ead06a4eef18d02a83846792d4aec156122f32
7 files changed
+306
-21
plugins/_document_query/README.md
+5
-1
@@ -8,7 +8,7 @@ timeouts and thread-safe parsers.
8
- **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes
9
- **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers
10
- **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks
11
-- **Thread-safe execution** - all sync parsers offloaded to asyncio.to_thread
11
+- **Bounded parser execution** - sync parsers are offloaded to asyncio.to_thread and globally capped across chats
12
- **Configurable timeouts** - per-document and gather-level timeouts
13
- **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured
14
@@ -23,10 +23,14 @@ See default_config.yaml for all options. Key settings:
23
| max_remote_bytes | 52428800 | Max remote document size |
24
| per_document_timeout | 60 | Max time for a single document parse |
25
| gather_timeout | 120 | Max time for all documents combined |
26
+| parser_concurrency | 1 | Max parser jobs running across all chats in one process |
27
+| context_intro_chunks | 2 | Leading chunks included per document for title/abstract grounding |
28
| chunk_size | 1000 | Text splitter chunk size |
29
| chunk_overlap | 100 | Text splitter overlap |
30
| search_threshold | 0.5 | Similarity search threshold |
31
| liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks |
32
+| liteparse_num_workers | 1 | Max LiteParse OCR workers per parser job |
33
+| liteparse_subprocess | true | Run LiteParse in a child process so native crashes fall back safely |
34
| thread_offload | true | Offload sync parsers to thread pool |
35
36
LiteParse is installed into the Agent Zero framework runtime from hooks.py during
plugins/_document_query/default_config.yaml
+4
-1
@@ -9,6 +9,8 @@ per_document_timeout: 60 # max time for a single document parse
9
gather_timeout: 120 # max time for all documents combined in one call
10
11
# --- Parser settings ---
12
+parser_concurrency: 1 # max parser jobs running across all chats in this process
13
+context_intro_chunks: 2 # always include leading chunks per document for title/abstract grounding
14
chunk_size: 1000
15
chunk_overlap: 100
16
search_threshold: 0.5
@@ -26,6 +28,7 @@ liteparse_target_pages:
28
liteparse_dpi: 150
29
liteparse_preserve_very_small_text: false
30
liteparse_output_format: text
29
-liteparse_num_workers:
31
+liteparse_num_workers: 1 # LiteParse defaults to CPU cores - 1; cap it for web runtime stability
32
+liteparse_subprocess: true # isolate LiteParse native runtime crashes from the Web UI process
33
pdf_ocr_fallback: true # enable legacy Tesseract fallback after PyMuPDF
34
thread_offload: true # offload sync parsers to thread pool
plugins/_document_query/helpers/document_query.py
+116
-16
@@ -7,8 +7,9 @@ a thread pool and bounded by configurable timeouts.
7
8
import asyncio
9
import json
10
+import threading
11
from datetime import datetime
11
-from typing import Callable, List, Optional, Sequence, Tuple
12
+from typing import Any, Callable, List, Optional, Sequence, Tuple
13
from urllib.parse import urlparse
14
15
from langchain.schema import SystemMessage, HumanMessage
@@ -25,6 +26,33 @@ from plugins._document_query.helpers.parsers import BaseParser, get_parsers_for_
26
27
28
DEFAULT_SEARCH_THRESHOLD = 0.5
29
+DEFAULT_PARSER_CONCURRENCY = 1
30
+SMALL_DOCUMENT_FALLBACK_MAX_CHARS = 12000
31
+_PARSER_SEMAPHORES: dict[tuple[int, int], asyncio.Semaphore] = {}
32
+_PARSER_SEMAPHORES_LOCK = threading.Lock()
33
+
34
+
35
+def _positive_int(value: Any, default: int) -> int:
36
+ try:
37
+ parsed = int(value)
38
+ except (TypeError, ValueError):
39
+ return default
40
+ return parsed if parsed > 0 else default
41
+
42
+
43
+def _parser_semaphore(config: dict) -> asyncio.Semaphore:
44
+ concurrency = _positive_int(
45
+ config.get("parser_concurrency"),
46
+ DEFAULT_PARSER_CONCURRENCY,
47
+ )
48
+ loop = asyncio.get_running_loop()
49
+ key = (id(loop), concurrency)
50
+ with _PARSER_SEMAPHORES_LOCK:
51
+ semaphore = _PARSER_SEMAPHORES.get(key)
52
+ if semaphore is None:
53
+ semaphore = asyncio.Semaphore(concurrency)
54
+ _PARSER_SEMAPHORES[key] = semaphore
55
+ return semaphore
56
57
58
def _load_config(agent: Agent) -> dict:
@@ -207,7 +235,7 @@ class DocumentQueryHelper:
235
236
gather_timeout = self.config.get("gather_timeout", 120)
237
try:
210
- await asyncio.wait_for(
238
+ document_contents = await asyncio.wait_for(
239
asyncio.gather(
240
*[self.document_get_content(uri, True) for uri in document_uris]
241
),
@@ -220,6 +248,14 @@ class DocumentQueryHelper:
248
search_threshold = self.config.get("search_threshold", DEFAULT_SEARCH_THRESHOLD)
249
search_limit = self.config.get("search_limit", 100)
250
selected_chunks = {}
251
+ normalized_uris = [self.store.normalize_uri(uri) for uri in document_uris]
252
+ intro_chunk_count = _positive_int(
253
+ self.config.get("context_intro_chunks"),
254
+ 2,
255
+ )
256
+ for uri in normalized_uris:
257
+ for chunk in await self._get_document_intro_chunks(uri, intro_chunk_count):
258
+ selected_chunks[chunk.metadata["id"]] = chunk
259
260
for question in questions:
261
self.progress_callback(f"Optimizing query: {question}")
@@ -233,7 +269,6 @@ class DocumentQueryHelper:
269
270
await self.agent.handle_intervention()
271
self.progress_callback(f"Searching documents with query: {optimized_query}")
236
- normalized_uris = [self.store.normalize_uri(uri) for uri in document_uris]
272
doc_filter = " or ".join(
273
[f"document_uri == '{uri}'" for uri in normalized_uris]
274
)
@@ -246,18 +281,48 @@ class DocumentQueryHelper:
281
selected_chunks[chunk.metadata["id"]] = chunk
282
283
if not selected_chunks:
284
+ fallback_content = self._small_document_fallback_content(
285
+ document_uris,
286
+ document_contents,
287
+ )
288
+ if fallback_content:
289
+ self.progress_callback(
290
+ "No matching chunks found; using extracted document content"
291
+ )
292
+ ai_response = await self._answer_questions_from_content(
293
+ fallback_content,
294
+ questions,
295
+ "extracted document content",
296
+ )
297
+ self.progress_callback(f"Q&A process completed")
298
+ return True, ai_response
299
+
300
self.progress_callback("No relevant content found in the documents")
301
content = f"!!! No content found for documents: {json.dumps(document_uris)} matching queries: {json.dumps(questions)}"
302
return False, content
303
304
+ content = "\n\n----\n\n".join(
305
+ [chunk.page_content for chunk in selected_chunks.values()]
306
+ )
307
+ ai_response = await self._answer_questions_from_content(
308
+ content,
309
+ questions,
310
+ f"{len(selected_chunks)} chunks",
311
+ )
312
+ self.progress_callback(f"Q&A process completed")
313
+ return True, ai_response
314
+
315
+ async def _answer_questions_from_content(
316
+ self,
317
+ content: str,
318
+ questions: Sequence[str],
319
+ context_label: str,
320
+ ) -> str:
321
self.progress_callback(
254
- f"Processing {len(questions)} questions in context of {len(selected_chunks)} chunks"
322
+ f"Processing {len(questions)} questions in context of {context_label}"
323
)
324
await self.agent.handle_intervention()
325
questions_str = "\n".join([f" * {question}" for question in questions])
258
- content = "\n\n----\n\n".join(
259
- [chunk.page_content for chunk in selected_chunks.values()]
260
- )
326
qa_system_message = self.agent.parse_prompt("fw.document_query.system_prompt.md")
327
qa_user_message = f"# Document:\n{content}\n\n# Queries:\n{questions_str}"
328
ai_response, _reasoning = await self.agent.call_chat_model(
@@ -267,8 +332,41 @@ class DocumentQueryHelper:
332
],
333
explicit_caching=False,
334
)
270
- self.progress_callback(f"Q&A process completed")
271
- return True, str(ai_response)
335
+ return str(ai_response)
336
+
337
+ @staticmethod
338
+ def _small_document_fallback_content(
339
+ document_uris: Sequence[str],
340
+ document_contents: Sequence[str],
341
+ max_chars: int = SMALL_DOCUMENT_FALLBACK_MAX_CHARS,
342
+ ) -> str:
343
+ blocks = []
344
+ for document_uri, document_content in zip(document_uris, document_contents):
345
+ text = (document_content or "").strip()
346
+ if text:
347
+ blocks.append(f"# Source: {document_uri}\n\n{text}")
348
+
349
+ if not blocks:
350
+ return ""
351
+
352
+ content = "\n\n----\n\n".join(blocks)
353
+ if len(content) > max_chars:
354
+ return ""
355
+ return content
356
+
357
+ async def _get_document_intro_chunks(
358
+ self,
359
+ document_uri: str,
360
+ limit: int,
361
+ ) -> list[Document]:
362
+ if limit <= 0:
363
+ return []
364
+ if not hasattr(self.store, "_get_document_chunks"):
365
+ return []
366
+ chunks = await self.store._get_document_chunks(document_uri)
367
+ return sorted(chunks, key=lambda chunk: chunk.metadata.get("chunk_index", 0))[
368
+ :limit
369
+ ]
370
371
async def document_get_content(
372
self, document_uri: str, add_to_db: bool = False
@@ -328,15 +426,17 @@ class DocumentQueryHelper:
426
thread_offload: bool,
427
) -> str:
428
errors_seen = []
429
+ semaphore = _parser_semaphore(self.config)
430
for parser in parsers:
431
try:
333
- self.progress_callback("Parsing document content")
334
- content = await parser.parse(
335
- document=document,
336
- config=self.config,
337
- timeout=timeout,
338
- thread_offload=thread_offload,
339
- )
432
+ async with semaphore:
433
+ self.progress_callback("Parsing document content")
434
+ content = await parser.parse(
435
+ document=document,
436
+ config=self.config,
437
+ timeout=timeout,
438
+ thread_offload=thread_offload,
439
+ )
440
if content:
441
return content
442
errors_seen.append(f"{parser.__class__.__name__}: no content")
plugins/_document_query/helpers/parsers/liteparse.py
+72
-3
@@ -2,7 +2,10 @@
2
3
from __future__ import annotations
4
5
+import json
6
import os
7
+import subprocess
8
+import sys
9
from pathlib import Path
10
11
from plugins._document_query.helpers.fetch import FetchedDocument
@@ -12,6 +15,8 @@ from .base import BaseParser
15
class LiteParseParser(BaseParser):
16
"""Fast parser powered by run-llama/liteparse when available."""
17
18
+ DEFAULT_NUM_WORKERS = 1
19
+
20
mimetypes = [
21
"application/pdf",
22
"application/msword",
@@ -30,6 +35,11 @@ class LiteParseParser(BaseParser):
35
return bool(config.get("liteparse_enabled", True))
36
37
def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
38
+ if config.get("liteparse_subprocess", True):
39
+ return self._parse_subprocess(document, config)
40
+ return self._parse_in_process(document, config)
41
+
42
+ def _parse_in_process(self, document: FetchedDocument, config: dict) -> str:
43
try:
44
from liteparse import LiteParse
45
except Exception as e:
@@ -44,6 +54,56 @@ class LiteParseParser(BaseParser):
54
raise ValueError("LiteParse returned no text")
55
return text
56
57
+ def _parse_subprocess(self, document: FetchedDocument, config: dict) -> str:
58
+ with document.local_file() as file_path:
59
+ payload = {
60
+ "file_path": file_path,
61
+ "kwargs": self._liteparse_kwargs(config),
62
+ }
63
+ env = os.environ.copy()
64
+ project_root = str(Path(__file__).resolve().parents[4])
65
+ python_path = env.get("PYTHONPATH", "")
66
+ env["PYTHONPATH"] = (
67
+ f"{project_root}{os.pathsep}{python_path}"
68
+ if python_path
69
+ else project_root
70
+ )
71
+ timeout = float(config.get("per_document_timeout", 60))
72
+ result = subprocess.run(
73
+ [
74
+ sys.executable,
75
+ "-m",
76
+ "plugins._document_query.helpers.parsers.liteparse_worker",
77
+ ],
78
+ input=json.dumps(payload),
79
+ text=True,
80
+ capture_output=True,
81
+ cwd=project_root,
82
+ env=env,
83
+ timeout=timeout,
84
+ check=False,
85
+ )
86
+
87
+ if result.returncode != 0:
88
+ detail = (result.stderr or result.stdout or "").strip()
89
+ raise RuntimeError(
90
+ "LiteParse subprocess failed"
91
+ f" with exit code {result.returncode}: {detail[-2000:]}"
92
+ )
93
+
94
+ try:
95
+ response = json.loads(result.stdout)
96
+ except json.JSONDecodeError as e:
97
+ raise RuntimeError(
98
+ "LiteParse subprocess returned invalid output: "
99
+ f"{result.stdout[-2000:]}"
100
+ ) from e
101
+
102
+ text = response.get("text", "") or ""
103
+ if not text.strip():
104
+ raise ValueError("LiteParse returned no text")
105
+ return text
106
+
107
def _liteparse_kwargs(self, config: dict) -> dict:
108
kwargs = {
109
"ocr_enabled": bool(config.get("liteparse_ocr_enabled", True)),
@@ -67,9 +127,10 @@ class LiteParseParser(BaseParser):
127
if value not in (None, ""):
128
kwargs[liteparse_key] = value
129
70
- num_workers = config.get("liteparse_num_workers")
71
- if num_workers not in (None, ""):
72
- kwargs["num_workers"] = int(num_workers)
130
+ kwargs["num_workers"] = _positive_int(
131
+ config.get("liteparse_num_workers"),
132
+ self.DEFAULT_NUM_WORKERS,
133
+ )
134
135
tessdata_path = config.get("liteparse_tessdata_path") or _detect_tessdata_path()
136
if tessdata_path:
@@ -91,3 +152,11 @@ def _detect_tessdata_path() -> str:
152
if candidate and (Path(candidate) / "eng.traineddata").is_file():
153
return candidate
154
return ""
155
+
156
+
157
+def _positive_int(value, default: int) -> int:
158
+ try:
159
+ parsed = int(value)
160
+ except (TypeError, ValueError):
161
+ return default
162
+ return parsed if parsed > 0 else default
plugins/_document_query/helpers/parsers/liteparse_worker.py
new
+24
@@ -0,0 +1,24 @@
1
+"""Subprocess entry point for isolating LiteParse native runtime crashes."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+import sys
7
+
8
+
9
+def main() -> int:
10
+ payload = json.load(sys.stdin)
11
+ file_path = payload["file_path"]
12
+ kwargs = payload.get("kwargs") or {}
13
+
14
+ from liteparse import LiteParse
15
+
16
+ parser = LiteParse(**kwargs)
17
+ result = parser.parse(file_path)
18
+ text = getattr(result, "text", "") or ""
19
+ json.dump({"text": text}, sys.stdout)
20
+ return 0
21
+
22
+
23
+if __name__ == "__main__":
24
+ raise SystemExit(main())
tests/test_document_query_fallback.py
+1
@@ -44,6 +44,7 @@ def test_document_qa_uses_small_document_content_when_search_finds_no_chunks():
44
helper = object.__new__(DocumentQueryHelper)
45
helper.agent = agent
46
helper.store = FakeStore()
47
+ helper.config = {}
48
helper.progress_callback = progress.append
49
50
async def document_get_content(uri, add_to_db=False):
tests/test_document_query_plugin.py
+84
@@ -9,6 +9,7 @@ from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_
9
from plugins._document_query.helpers.document_query import DocumentQueryHelper
10
from plugins._document_query.helpers.parsers.base import BaseParser
11
from plugins._document_query.helpers.parsers import get_parsers_for_mimetype
12
+from plugins._document_query.helpers.parsers.liteparse import LiteParseParser
13
from plugins._document_query.helpers.parsers.text import TextParser
14
15
@@ -27,6 +28,24 @@ class ParserNameShouldNotLeak(BaseParser):
28
return "parsed"
29
30
31
+class CountingAsyncParser(BaseParser):
32
+ mimetypes = ["text/plain"]
33
+ active = 0
34
+ max_active = 0
35
+
36
+ async def _parse_async(self, document: FetchedDocument, config: dict) -> str:
37
+ type(self).active += 1
38
+ type(self).max_active = max(type(self).max_active, type(self).active)
39
+ try:
40
+ await asyncio.sleep(0.02)
41
+ return document.uri
42
+ finally:
43
+ type(self).active -= 1
44
+
45
+ def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
46
+ return document.uri
47
+
48
+
49
def test_fetch_file_detects_mimetype_and_reads_once(tmp_path):
50
document = tmp_path / "notes.txt"
51
document.write_text("hello\nworld\n", encoding="utf-8")
@@ -92,6 +111,25 @@ def test_liteparse_is_installed_by_docker_and_plugin_hook_requirements():
111
assert plugin_requirements.strip().splitlines() == ["liteparse>=2.0.0,<3.0.0"]
112
113
114
+def test_default_config_bounds_liteparse_runtime_concurrency():
115
+ default_config = (
116
+ ROOT / "plugins" / "_document_query" / "default_config.yaml"
117
+ ).read_text(encoding="utf-8")
118
+
119
+ assert "parser_concurrency: 1" in default_config
120
+ assert "context_intro_chunks: 2" in default_config
121
+ assert "liteparse_num_workers: 1" in default_config
122
+ assert "liteparse_subprocess: true" in default_config
123
+
124
+
125
+def test_liteparse_parser_caps_workers_by_default():
126
+ parser = LiteParseParser()
127
+
128
+ assert parser._liteparse_kwargs({})["num_workers"] == 1
129
+ assert parser._liteparse_kwargs({"liteparse_num_workers": "3"})["num_workers"] == 3
130
+ assert parser._liteparse_kwargs({"liteparse_num_workers": ""})["num_workers"] == 1
131
+
132
+
133
def test_query_optimize_prompt_filename_is_spelled_correctly():
134
prompt_dir = ROOT / "plugins" / "_document_query" / "prompts"
135
helper_source = (
@@ -129,6 +167,52 @@ def test_parser_progress_is_user_facing_and_generic():
167
assert progress == ["Parsing document content"]
168
169
170
+def test_parse_document_limits_parser_concurrency_across_helpers():
171
+ CountingAsyncParser.active = 0
172
+ CountingAsyncParser.max_active = 0
173
+ fetched_a = FetchedDocument(
174
+ uri="/tmp/a.txt",
175
+ source_uri="/tmp/a.txt",
176
+ scheme="file",
177
+ mimetype="text/plain",
178
+ content=b"a",
179
+ local_path=None,
180
+ )
181
+ fetched_b = FetchedDocument(
182
+ uri="/tmp/b.txt",
183
+ source_uri="/tmp/b.txt",
184
+ scheme="file",
185
+ mimetype="text/plain",
186
+ content=b"b",
187
+ local_path=None,
188
+ )
189
+ helper_a = object.__new__(DocumentQueryHelper)
190
+ helper_a.config = {"parser_concurrency": 1}
191
+ helper_a.progress_callback = lambda _msg: None
192
+ helper_b = object.__new__(DocumentQueryHelper)
193
+ helper_b.config = {"parser_concurrency": 1}
194
+ helper_b.progress_callback = lambda _msg: None
195
+
196
+ async def parse_both():
197
+ return await asyncio.gather(
198
+ helper_a._parse_document(
199
+ document=fetched_a,
200
+ parsers=[CountingAsyncParser()],
201
+ timeout=1,
202
+ thread_offload=False,
203
+ ),
204
+ helper_b._parse_document(
205
+ document=fetched_b,
206
+ parsers=[CountingAsyncParser()],
207
+ timeout=1,
208
+ thread_offload=False,
209
+ ),
210
+ )
211
+
212
+ assert sorted(run_async(parse_both())) == ["/tmp/a.txt", "/tmp/b.txt"]
213
+ assert CountingAsyncParser.max_active == 1
214
+
215
+
216
def test_document_query_prompt_uses_progressive_skill_disclosure():
217
from helpers.skills import find_skill
218