From 798ba57f0b73a6c4a073181a8de904de17a7b2fa Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:35:22 +0000
Subject: [PATCH] Reply to chat in the user's UI language (#7766)
# Description of Changes
Pass a user browser lang ID to engine
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] Every comment I added says something the code does not
([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
.../model/api/ai/AiWorkflowRequest.java | 3 +
.../service/AiWorkflowService.java | 5 ++
engine/src/stirling/agents/orchestrator.py | 5 +-
.../src/stirling/agents/pdf_comment/agent.py | 4 +-
.../src/stirling/agents/pdf_create/agent.py | 5 +-
engine/src/stirling/agents/pdf_edit.py | 3 +-
engine/src/stirling/agents/pdf_questions.py | 4 +-
engine/src/stirling/agents/pdf_review.py | 4 +-
engine/src/stirling/agents/user_spec.py | 4 +-
engine/src/stirling/contracts/orchestrator.py | 2 +
engine/src/stirling/services/__init__.py | 3 +
engine/src/stirling/services/language.py | 23 +++++++
engine/tests/test_reply_language.py | 66 +++++++++++++++++++
.../components/chat/ChatContext.tsx | 4 +-
14 files changed, 127 insertions(+), 8 deletions(-)
create mode 100644 engine/src/stirling/services/language.py
create mode 100644 engine/tests/test_reply_language.py
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java
index ad4c994c05..f990c4eb57 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java
@@ -25,4 +25,7 @@ public class AiWorkflowRequest {
"Prior chat messages exchanged between the user and the assistant, ordered"
+ " oldest-first. Excludes the current userMessage.")
private List conversationHistory = new ArrayList<>();
+
+ @Schema(description = "IETF language tag the reply should be written in", example = "fr-FR")
+ private String locale;
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
index b9e3488324..80cd0cff7f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
@@ -185,6 +185,7 @@ public class AiWorkflowService {
initialRequest.setConversationHistory(
new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
+ initialRequest.setLocale(request.getLocale());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
WorkflowState state = new WorkflowState.Pending(initialRequest);
@@ -287,6 +288,7 @@ public class AiWorkflowService {
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setEnabledEndpoints(request.getEnabledEndpoints());
+ nextRequest.setLocale(request.getLocale());
return new WorkflowState.Pending(nextRequest);
} finally {
for (LoadedFile lf : loadedFiles) {
@@ -338,6 +340,7 @@ public class AiWorkflowService {
nextRequest.setFiles(request.getFiles());
nextRequest.setConversationHistory(request.getConversationHistory());
nextRequest.setResumeWith(response.getResumeWith());
+ nextRequest.setLocale(request.getLocale());
return new WorkflowState.Pending(nextRequest);
}
@@ -530,6 +533,7 @@ public class AiWorkflowService {
new PdfContentExtractor.ToolReportArtifact(
result.reportTool(), result.report()));
resumeRequest.setResumeWith(resumeWith);
+ resumeRequest.setLocale(previousRequest.getLocale());
return new WorkflowState.Pending(resumeRequest);
}
@@ -802,5 +806,6 @@ public class AiWorkflowService {
private List artifacts = new ArrayList<>();
private String resumeWith;
private List enabledEndpoints = new ArrayList<>();
+ private String locale;
}
}
diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py
index bb7f2682df..0ef4345e06 100644
--- a/engine/src/stirling/agents/orchestrator.py
+++ b/engine/src/stirling/agents/orchestrator.py
@@ -30,7 +30,7 @@ from stirling.contracts import (
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
-from stirling.services import AppRuntime
+from stirling.services import AppRuntime, language_directive, set_reply_locale
logger = logging.getLogger(__name__)
@@ -153,6 +153,8 @@ class OrchestratorAgent:
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
+ # Bound once; delegates and worker tasks inherit it.
+ set_reply_locale(request.locale)
logger.info(
"[orchestrator] handle: files=%s resume_with=%s artifacts=%s msg=%r",
[file.name for file in request.files],
@@ -270,6 +272,7 @@ class OrchestratorAgent:
f"User message: {request.user_message}\n"
f"Files: {format_file_names(request.files)}\n"
f"Available artifacts:\n{artifact_summary}"
+ f"\n{language_directive()}"
)
def _describe_artifacts(self, request: OrchestratorRequest) -> str:
diff --git a/engine/src/stirling/agents/pdf_comment/agent.py b/engine/src/stirling/agents/pdf_comment/agent.py
index 9b62629a47..6a01c88103 100644
--- a/engine/src/stirling/agents/pdf_comment/agent.py
+++ b/engine/src/stirling/agents/pdf_comment/agent.py
@@ -30,7 +30,7 @@ from stirling.contracts.pdf_comments import (
)
from stirling.logging import Pretty
from stirling.models import ApiModel
-from stirling.services import AppRuntime
+from stirling.services import AppRuntime, language_directive
logger = logging.getLogger(__name__)
@@ -160,6 +160,8 @@ class PdfCommentAgent:
]
for index, chunk in enumerate(request.chunks):
lines.append(f"[{index}] page={chunk.page + 1} text={json.dumps(chunk.text)}")
+ # Last, after the untrusted chunk text.
+ lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@staticmethod
diff --git a/engine/src/stirling/agents/pdf_create/agent.py b/engine/src/stirling/agents/pdf_create/agent.py
index 671d4867ca..bb0d567186 100644
--- a/engine/src/stirling/agents/pdf_create/agent.py
+++ b/engine/src/stirling/agents/pdf_create/agent.py
@@ -45,7 +45,7 @@ from stirling.contracts.pdf_create import (
WrittenSections,
)
from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams
-from stirling.services import AppRuntime
+from stirling.services import AppRuntime, language_directive
logger = logging.getLogger(__name__)
@@ -260,6 +260,7 @@ def _build_sections_prompt(meta: DocumentMeta, user_request: str, history: str)
lines.append(f"\nConversation history:\n{history}")
lines.append(f"\nUser request: {user_request}")
+ lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@@ -292,6 +293,7 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
for point in s.key_points:
lines.append(f" - {point}")
+ lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@@ -338,6 +340,7 @@ class PdfCreateAgent:
# ── Phase 1: plan meta ─────────────────────────────────────────────────
logger.info("[pdf-create] phase 1/6: planning document meta")
meta_prompt = f"Conversation history:\n{history}\n\nUser request: {request.user_message}"
+ meta_prompt += f"\n\n{language_directive()}"
meta_result = await self._meta_planner.run(meta_prompt)
meta = meta_result.output
diff --git a/engine/src/stirling/agents/pdf_edit.py b/engine/src/stirling/agents/pdf_edit.py
index c46fdb9f24..5f89bdcc58 100644
--- a/engine/src/stirling/agents/pdf_edit.py
+++ b/engine/src/stirling/agents/pdf_edit.py
@@ -27,7 +27,7 @@ from stirling.contracts import (
)
from stirling.logging import Pretty
from stirling.models import OPERATIONS, ApiModel, ParamToolModel, ToolEndpoint
-from stirling.services import AppRuntime, ToolChainStep, blocking, validate_tool_chain
+from stirling.services import AppRuntime, ToolChainStep, blocking, language_directive, validate_tool_chain
logger = logging.getLogger(__name__)
@@ -357,6 +357,7 @@ class PdfEditAgent:
f"{unavailable_line}"
f"{repair_line}"
f"Extracted page text:\n{format_page_text(request.page_text)}"
+ f"\n{language_directive()}"
)
# Endpoints that exist on the server and are callable via the direct API or the manual UI,
diff --git a/engine/src/stirling/agents/pdf_questions.py b/engine/src/stirling/agents/pdf_questions.py
index f594c2fdd7..b17f799595 100644
--- a/engine/src/stirling/agents/pdf_questions.py
+++ b/engine/src/stirling/agents/pdf_questions.py
@@ -29,7 +29,7 @@ from stirling.contracts import (
from stirling.documents import RagCapability
from stirling.models import PrincipalId
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
-from stirling.services import AppRuntime, require_current_user_id
+from stirling.services import AppRuntime, language_directive, require_current_user_id
logger = logging.getLogger(__name__)
@@ -223,6 +223,7 @@ class PdfQuestionAgent:
forbids invented figures; the LLM only restates Verdict facts.
"""
prompt = f"User question:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
+ prompt += f"\n\n{language_directive()}"
result = await self._math_synth_agent.run(prompt)
return result.output
@@ -233,4 +234,5 @@ class PdfQuestionAgent:
f"Files: {format_file_names(request.files)}\n"
f"Question: {request.question}\n"
"Pick the right retrieval tool for this question, then answer from what it returns."
+ f"\n{language_directive()}"
)
diff --git a/engine/src/stirling/agents/pdf_review.py b/engine/src/stirling/agents/pdf_review.py
index 5aca029a8a..b4fc847a1d 100644
--- a/engine/src/stirling/agents/pdf_review.py
+++ b/engine/src/stirling/agents/pdf_review.py
@@ -56,7 +56,7 @@ from stirling.models.agent_tool_models import (
PdfCommentAgentParams,
)
from stirling.models.tool_models import AddCommentsParams
-from stirling.services import AppRuntime, require_current_user_id
+from stirling.services import AppRuntime, language_directive, require_current_user_id
# Fallback right-margin placement used when a finding has no usable
# anchor text. A4/Letter portrait assumed.
@@ -209,6 +209,7 @@ class PdfReviewAgent:
placement geometry to produce the JSON the ``add-comments`` tool wants.
"""
prompt = f"User review request:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
+ prompt += f"\n\n{language_directive()}"
result = await self._localiser_agent.run(prompt)
specs = self._build_comment_specs(verdict, result.output.comments)
serialised = [spec.model_dump(by_alias=True, exclude_none=True) for spec in specs]
@@ -238,6 +239,7 @@ class PdfReviewAgent:
prompt = (
f"{_escape_for_tag(user_message)}\n"
f"{_escape_for_tag(report.model_dump_json())}"
+ f"\n{language_directive()}"
)
result = await self._contradiction_localiser.run(prompt)
specs = self._build_paired_comment_specs(report, result.output.comments)
diff --git a/engine/src/stirling/agents/user_spec.py b/engine/src/stirling/agents/user_spec.py
index 2ba367246b..cf1b378323 100644
--- a/engine/src/stirling/agents/user_spec.py
+++ b/engine/src/stirling/agents/user_spec.py
@@ -21,7 +21,7 @@ from stirling.contracts import (
format_conversation_history,
)
from stirling.models import ApiModel
-from stirling.services import AppRuntime
+from stirling.services import AppRuntime, language_directive
class UserSpecMetadata(ApiModel):
@@ -98,6 +98,7 @@ class UserSpecAgent:
f"Edit plan summary:\n{edit_plan.summary}\n\n"
f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n"
f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}"
+ f"\n\n{language_directive()}"
)
def _build_revision_prompt(self, request: AgentRevisionRequest, edit_plan: EditPlanResponse) -> str:
@@ -108,6 +109,7 @@ class UserSpecAgent:
f"Edit plan summary:\n{edit_plan.summary}\n\n"
f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n"
f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}"
+ f"\n\n{language_directive()}"
)
async def _build_edit_plan(
diff --git a/engine/src/stirling/contracts/orchestrator.py b/engine/src/stirling/contracts/orchestrator.py
index 2d85853c51..d7cc9810a6 100644
--- a/engine/src/stirling/contracts/orchestrator.py
+++ b/engine/src/stirling/contracts/orchestrator.py
@@ -42,6 +42,8 @@ class OrchestratorRequest(ApiModel):
conversation_history: list[ConversationMessage] = Field(default_factory=list)
artifacts: list[WorkflowArtifact] = Field(default_factory=list)
resume_with: SupportedCapability | None = None
+ # Reply language (IETF tag); unset falls back to the message's own language.
+ locale: str | None = None
# See `PdfEditRequest.enabled_endpoints`.
enabled_endpoints: Annotated[list[ToolEndpoint], BeforeValidator(drop_unknown_tool_endpoints)] = Field(
default_factory=list
diff --git a/engine/src/stirling/services/__init__.py b/engine/src/stirling/services/__init__.py
index 4488409142..618c2f8fef 100644
--- a/engine/src/stirling/services/__init__.py
+++ b/engine/src/stirling/services/__init__.py
@@ -1,5 +1,6 @@
"""Shared services used by the Stirling AI runtime."""
+from .language import language_directive, set_reply_locale
from .progress import (
ProgressEmitter,
emit_progress,
@@ -20,9 +21,11 @@ __all__ = [
"build_runtime",
"current_user_id",
"emit_progress",
+ "language_directive",
"require_current_user_id",
"reset_progress_emitter",
"set_progress_emitter",
+ "set_reply_locale",
"setup_posthog_tracking",
"validate_tool_chain",
]
diff --git a/engine/src/stirling/services/language.py b/engine/src/stirling/services/language.py
new file mode 100644
index 0000000000..cfb802d77c
--- /dev/null
+++ b/engine/src/stirling/services/language.py
@@ -0,0 +1,23 @@
+"""Per-request reply language, bound by the orchestrator, read by prompt builders."""
+
+from __future__ import annotations
+
+from contextvars import ContextVar
+
+_locale: ContextVar[str | None] = ContextVar("stirling_reply_locale", default=None)
+
+
+def set_reply_locale(locale: str | None) -> None:
+ _locale.set(locale)
+
+
+def language_directive() -> str:
+ """Prompt line pinning the reply language; append to any user-facing prompt."""
+ locale = _locale.get()
+ if not locale:
+ return "Write anything the user will read in the same language as their message."
+ return (
+ f"Write anything the user will read in the language of locale '{locale}', whatever "
+ "language this prompt, the documents, or the tool output are in. Only a different "
+ "language the user explicitly asks for overrides this."
+ )
diff --git a/engine/tests/test_reply_language.py b/engine/tests/test_reply_language.py
new file mode 100644
index 0000000000..ad4d9570aa
--- /dev/null
+++ b/engine/tests/test_reply_language.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Any, cast
+
+import pytest
+
+from stirling.agents import OrchestratorAgent
+from stirling.agents.pdf_questions import PdfQuestionAgent
+from stirling.contracts import (
+ OrchestratorRequest,
+ PdfQuestionAnswerResponse,
+ PdfQuestionRequest,
+ SupportedCapability,
+)
+from stirling.services import language_directive, set_reply_locale
+from stirling.services.runtime import AppRuntime
+
+
+@pytest.fixture(autouse=True)
+def reset_locale() -> Iterator[None]:
+ set_reply_locale(None)
+ yield
+ set_reply_locale(None)
+
+
+def test_directive_falls_back_to_the_message_language() -> None:
+ assert "same language as their message" in language_directive()
+
+
+def test_directive_pins_the_bound_locale() -> None:
+ set_reply_locale("fr-FR")
+ assert "'fr-FR'" in language_directive()
+
+
+def test_orchestrator_request_carries_the_locale() -> None:
+ assert OrchestratorRequest.model_validate({"userMessage": "hi", "locale": "de-DE"}).locale == "de-DE"
+ assert OrchestratorRequest.model_validate({"userMessage": "hi"}).locale is None
+
+
+def test_question_prompt_carries_the_directive() -> None:
+ set_reply_locale("es-ES")
+ # _build_prompt ignores self, so call it off the class.
+ prompt = PdfQuestionAgent._build_prompt(cast(Any, None), PdfQuestionRequest(question="¿Cuántas páginas?"))
+ assert "'es-ES'" in prompt
+
+
+@pytest.mark.anyio
+async def test_handle_binds_the_locale_for_delegates(runtime: AppRuntime, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The resume path reaches a delegate with the request's locale already bound."""
+ agent = OrchestratorAgent(runtime)
+ seen: list[str] = []
+
+ async def capture(request: OrchestratorRequest) -> PdfQuestionAnswerResponse:
+ seen.append(language_directive())
+ return PdfQuestionAnswerResponse(answer="ok")
+
+ monkeypatch.setattr(agent, "_run_pdf_question", capture)
+ await agent.handle(
+ OrchestratorRequest(
+ user_message="Combien de pages ?",
+ locale="fr-FR",
+ resume_with=SupportedCapability.PDF_QUESTION,
+ )
+ )
+ assert "'fr-FR'" in seen[0]
diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx
index 595961365f..9ec18089a2 100644
--- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx
+++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx
@@ -402,7 +402,7 @@ const initialState: ChatState = {
};
export function ChatProvider({ children }: { children: ReactNode }) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const [state, dispatch] = useReducer(chatReducer, initialState);
const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles();
const { actions: fileActions } = useFileActions();
@@ -552,6 +552,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
try {
const formData = new FormData();
formData.append("userMessage", content);
+ // The engine replies in this language instead of guessing.
+ if (i18n.language) formData.append("locale", i18n.language);
sourceFiles.forEach((file, i) => {
formData.append(`fileInputs[${i}].fileInput`, file);
});