From b6402e07542bceb1afaa9b0789d17780e788ba73 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 1 Sep 2026 12:21:12 +0800 Subject: [PATCH 01/10] fix(agent): isolate response language from connector context --- src/xagent/core/agent/context/execution.py | 4 +- src/xagent/core/agent/language.py | 62 ++++++--- src/xagent/core/agent/pattern/dag/dag.py | 8 +- .../core/agent/pattern/dag/plan_generator.py | 1 + src/xagent/core/agent/pattern/react/react.py | 5 +- tests/core/agent/test_auto.py | 16 ++- tests/core/agent/test_context.py | 13 +- tests/core/agent/test_dag.py | 4 +- tests/core/agent/test_output_language_seam.py | 18 ++- .../agent/test_request_language_harness.py | 120 ++++++++++++++++++ 10 files changed, 207 insertions(+), 44 deletions(-) create mode 100644 tests/core/agent/test_request_language_harness.py diff --git a/src/xagent/core/agent/context/execution.py b/src/xagent/core/agent/context/execution.py index b3ccd1a2dd..dc18a5cdfd 100644 --- a/src/xagent/core/agent/context/execution.py +++ b/src/xagent/core/agent/context/execution.py @@ -547,7 +547,9 @@ def _system_context(self) -> str: output_language = effective_output_language(self) if current_task and not dag_step_id: language_directives = output_language_directives( - output_language, section="root_system_context" + output_language, + section="root_system_context", + request=self._current_user_request_text(prefer_display=True), ) parts.append( "Current user request:\n" diff --git a/src/xagent/core/agent/language.py b/src/xagent/core/agent/language.py index a3ae5fd346..cf41b3fa48 100644 --- a/src/xagent/core/agent/language.py +++ b/src/xagent/core/agent/language.py @@ -1,6 +1,7 @@ """Prompt snippets for user-facing response language, plus the checkpoint migration that keeps only a caller-provided language label.""" +import json import re from dataclasses import dataclass from typing import Any, Literal @@ -480,16 +481,47 @@ def response_language_rules(*, subject: str = "current user request") -> str: ) -def final_answer_language_rule(*, subject: str = "current user request") -> str: +def request_only_language_harness(request: str) -> str: + """Quote user-authored input as the only soft language decision source. + + The harness deliberately does not detect or persist a language label. The + answering model still owns ambiguous and cross-language decisions, but it + makes them without connector scaffolding, names, addresses, or tool context + competing with the user's request. + """ + request = request.strip() + if not request: + return response_language_rules() + return ( + "Request-only response language harness:\n" + "User-authored request (JSON string):\n" + f"{json.dumps(request, ensure_ascii=False)}\n\n" + "Decide the target language of user-facing prose from the user-authored " + "request above alone. Honor explicit and implicit requests to translate, " + "rewrite, or answer in another language. A person's name, email address, " + "connector metadata, quoted source content, memory, tool result, example, " + "or earlier turn is not evidence of the target language. For Chinese, " + "preserve Simplified Chinese versus Traditional Chinese from the request. " + "If the request is too short, mixed-language, or depends on conversation " + "context to determine a target language, resolve its meaning from the " + "conversation without guessing from auxiliary context. This quote controls " + "language only; it does not replace or narrow the executable request.\n\n" + f"{response_language_rules(subject='user-authored request above')}" + ) + + +def final_answer_language_rule( + *, subject: str = "authoritative output language guidance in the system context" +) -> str: """Return a compact language rule for final-answer tool fields.""" return ( - "The final answer must use the same natural language as the " - f"{subject}, even if tool results, source documents, retrieved memories, " - "examples, or earlier turns are written in another language. If the " - f"{subject} explicitly asks to translate, rewrite, or answer in another " - "language, use that requested target language. For Chinese, preserve " - "Simplified Chinese versus Traditional Chinese from the request; do not " - "collapse them into generic Chinese." + f"The final answer must follow the {subject}. Tool results, source " + "documents, retrieved memories, examples, names, email addresses, " + "connector metadata, and earlier turns must not change that language. " + "When the guidance quotes a user-authored request, honor any explicit or " + "implicit request to translate, rewrite, or answer in another language. " + "For Chinese, preserve Simplified Chinese versus Traditional Chinese from " + "the request; do not collapse them into generic Chinese." ) @@ -551,7 +583,7 @@ def output_language_directives( # beside it would hand the model a second, competing rule. if language: return f"Output language policy:\n{output_language_policy(language)}" - return response_language_rules() + return request_only_language_harness(request) if section == "dag_step_scope": return output_language_policy(language).strip() if section == "dag_step_rules": @@ -563,11 +595,7 @@ def output_language_directives( return "" # Quoted whole: any truncation can drop an explicit target-language # instruction sitting in the middle of a long request. - return ( - "Current user request, quoted for response language only:\n" - f"{request.strip()}\n\n" - "This request is not the executable goal for this step; use it " - "only to decide the natural language of user-facing prose.\n\n" - f"{response_language_rules()}" - ) - return output_language_policy(language) + return request_only_language_harness(request) + if language: + return output_language_policy(language) + return request_only_language_harness(request) diff --git a/src/xagent/core/agent/pattern/dag/dag.py b/src/xagent/core/agent/pattern/dag/dag.py index 3b0315238c..bd9d05256e 100644 --- a/src/xagent/core/agent/pattern/dag/dag.py +++ b/src/xagent/core/agent/pattern/dag/dag.py @@ -1503,6 +1503,8 @@ async def _assess_completion( return assessment def _completion_assessment_messages(self, context: Any) -> list[dict[str, Any]]: + language_request = latest_user_text(context, prefer_display=True) or "" + output_language = effective_output_language(context) latest_messages = [ {"role": message.role, "content": message.content} for message in getattr(context, "messages", []) @@ -1515,8 +1517,12 @@ def _completion_assessment_messages(self, context: Any) -> list[dict[str, Any]]: ] payload = { "output_language_policy": output_language_directives( - effective_output_language(context), + output_language, section="completion_assessment", + request=language_request, + ), + "user_authored_language_request": ( + "" if output_language else language_request ), "authoritative_user_requests": authoritative_user_requests, "messages": latest_messages, diff --git a/src/xagent/core/agent/pattern/dag/plan_generator.py b/src/xagent/core/agent/pattern/dag/plan_generator.py index 739295637c..95bb85a92b 100644 --- a/src/xagent/core/agent/pattern/dag/plan_generator.py +++ b/src/xagent/core/agent/pattern/dag/plan_generator.py @@ -579,6 +579,7 @@ def _build_prompt(self, request: PlanGenerationRequest) -> str: if language_source == OUTPUT_LANGUAGE_SOURCE_PLAN else expected_language, section="plan_payload", + request=latest_request, ), "messages": latest_messages, "retrieved_memory_context": request.context.metadata.get( diff --git a/src/xagent/core/agent/pattern/react/react.py b/src/xagent/core/agent/pattern/react/react.py index 09457f175d..b3c1b26678 100644 --- a/src/xagent/core/agent/pattern/react/react.py +++ b/src/xagent/core/agent/pattern/react/react.py @@ -132,10 +132,9 @@ class ReActReasoningMode(str, Enum): REACT_RESPONSE_LANGUAGE_DESCRIPTION = ( "Target natural language for user-facing prose in this ReAct response, " "for example English, Simplified Chinese, Traditional Chinese, or Spanish. " + "Follow the authoritative output language guidance in the system context. " "For Chinese requests, choose Simplified Chinese or Traditional Chinese to " - "match the request script; do not use generic Chinese. If the current user " - "request explicitly asks to answer in another language, use that requested " - "target language." + "match the request script; do not use generic Chinese." ) diff --git a/tests/core/agent/test_auto.py b/tests/core/agent/test_auto.py index feff9328ee..65b5297a76 100644 --- a/tests/core/agent/test_auto.py +++ b/tests/core/agent/test_auto.py @@ -25,7 +25,7 @@ OUTPUT_LANGUAGE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_PLAN, - response_language_rules, + request_only_language_harness, ) from xagent.core.agent.pattern.auto.auto import DECISION_TOOL_NAME, _AutoChildRuntime from xagent.core.model.chat.basic.router import RouterLLM @@ -2293,7 +2293,12 @@ async def test_stale_memory_language_does_not_reach_child_as_hard_policy() -> No assert "Output language:" not in child_system assert "Output language policy:" not in child_system assert "Summarize the quarterly revenue trend in one paragraph." in child_system - assert response_language_rules() in child_system + assert ( + request_only_language_harness( + "Summarize the quarterly revenue trend in one paragraph." + ) + in child_system + ) @pytest.mark.asyncio @@ -2319,10 +2324,7 @@ async def test_direct_final_answer_allows_an_explicit_target_language() -> None: assert result["success"] is True assert result["output"] == "La capitale de l'Italie est Rome." assert OUTPUT_LANGUAGE_METADATA_KEY not in context.metadata - target_rule = ( - "If the current user request explicitly asks to translate, rewrite, or " - "answer in another language, use that requested target language." - ) + target_rule = "honor any explicit or implicit request to translate" tool_schema = llm.calls[0]["tools"][0]["function"] assert target_rule in tool_schema["description"] assert ( @@ -2330,7 +2332,7 @@ async def test_direct_final_answer_allows_an_explicit_target_language() -> None: ) system_content = context.get_messages_for_llm()[0]["content"] assert request in system_content - assert target_rule in system_content + assert request_only_language_harness(request) in system_content class RoutedDecisionLLM: diff --git a/tests/core/agent/test_context.py b/tests/core/agent/test_context.py index 9647269a98..f62db96167 100644 --- a/tests/core/agent/test_context.py +++ b/tests/core/agent/test_context.py @@ -188,7 +188,10 @@ def test_system_context_preserves_current_request_language_over_memory() -> None assert "Current user request:" in system_message assert "Can you analyze this GitHub project?" in system_message assert "Response language rules" in system_message - assert "Use the same natural language as the current user request" in system_message + assert ( + "Use the same natural language as the user-authored request above" + in system_message + ) assert "Do not let retrieved memories" in system_message @@ -712,7 +715,7 @@ def test_dag_step_without_output_language_quotes_the_request_for_language() -> N system_content = ctx.get_messages_for_llm()[0]["content"] - assert "Current user request, quoted for response language only:" in system_content + assert "Request-only response language harness:" in system_content assert "Crée deux affiches." in system_content assert "Response language rules:" in system_content assert "Output language:" not in system_content @@ -744,11 +747,9 @@ def test_dag_step_language_quote_uses_the_typed_message() -> None: ) system_content = ctx.get_messages_for_llm()[0]["content"] - quote = system_content.split( - "Current user request, quoted for response language only:\n" - )[1] + quote = system_content.split("Request-only response language harness:\n")[1] - assert quote.startswith(typed) + assert typed in quote assert "Attached file(s)" not in quote diff --git a/tests/core/agent/test_dag.py b/tests/core/agent/test_dag.py index a7482e08b3..544c769d12 100644 --- a/tests/core/agent/test_dag.py +++ b/tests/core/agent/test_dag.py @@ -30,7 +30,7 @@ OUTPUT_LANGUAGE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_PLAN, - response_language_rules, + request_only_language_harness, ) from xagent.core.agent.pattern.base import RequiredToolCallError from xagent.core.agent.pattern.dag import dag as dag_module @@ -5189,7 +5189,7 @@ async def run(self, **kwargs: Any) -> dict[str, Any]: assert "Output language: Simplified Chinese" not in system_content assert "Output language:" not in system_content assert request in system_content - assert response_language_rules() in system_content + assert request_only_language_harness(request) in system_content step_instruction = [ message.content for message in child.messages diff --git a/tests/core/agent/test_output_language_seam.py b/tests/core/agent/test_output_language_seam.py index 0e99266375..4943413712 100644 --- a/tests/core/agent/test_output_language_seam.py +++ b/tests/core/agent/test_output_language_seam.py @@ -10,6 +10,7 @@ effective_output_language, output_language_directives, output_language_policy, + request_only_language_harness, response_language_rules, ) from xagent.core.agent.pattern.dag.dag import DAGPattern @@ -112,10 +113,12 @@ def test_output_language_directives_render_each_section_verbatim() -> None: "plan_payload", ) for section in sections: - for label in ("Japanese", ""): - assert output_language_directives( - label, section=section - ) == output_language_policy(label) + assert output_language_directives( + "Japanese", section=section + ) == output_language_policy("Japanese") + assert output_language_directives("", section=section) == ( + request_only_language_harness("") + ) def test_every_consumer_renders_the_resolved_language() -> None: @@ -142,8 +145,9 @@ def test_every_consumer_renders_the_resolved_language() -> None: def test_every_consumer_falls_back_when_no_language_is_recorded() -> None: + request = "Summarize the repository" assert ( - output_language_directives("", section="root_system_context") + output_language_directives("", section="root_system_context", request=request) in _root_context()._system_context() ) assert ( @@ -153,8 +157,8 @@ def test_every_consumer_falls_back_when_no_language_is_recorded() -> None: assert output_language_directives( "", section="dag_step_instruction" ) in _step_instruction(None) - assert _completion_policy(None) == output_language_policy("") - assert _plan_payload_policy(None) == output_language_policy("") + assert _completion_policy(None) == request_only_language_harness(request) + assert _plan_payload_policy(None) == request_only_language_harness(request) def test_consumers_normalize_an_aliased_language_label() -> None: diff --git a/tests/core/agent/test_request_language_harness.py b/tests/core/agent/test_request_language_harness.py new file mode 100644 index 0000000000..010b9d06b2 --- /dev/null +++ b/tests/core/agent/test_request_language_harness.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json + +import pytest + +from xagent.core.agent.context import ExecutionContext +from xagent.core.agent.language import ( + OUTPUT_LANGUAGE_METADATA_KEY, + request_only_language_harness, +) +from xagent.core.agent.pattern.auto.auto import AutoPattern +from xagent.core.agent.pattern.dag.dag import DAGPattern +from xagent.core.agent.pattern.dag.plan_generator import ( + LLMPlanGenerator, + PlanGenerationRequest, +) +from xagent.core.agent.pattern.react.react import ReActPattern + + +ENGLISH_REQUEST = "Summarize the latest customer email and draft a concise reply." +POLLUTED_EXECUTION_REQUEST = ( + f"{ENGLISH_REQUEST}\n\n" + "[From: Gerard Santos ]\n" + "Connector context: bandeja de entrada, correo electrónico, responder." +) + + +def _polluted_context() -> ExecutionContext: + context = ExecutionContext(execution_id="request-language-harness") + context.add_user_message( + POLLUTED_EXECUTION_REQUEST, + metadata={"display_message": ENGLISH_REQUEST}, + ) + return context + + +@pytest.mark.parametrize( + "user_request", + [ + "Translate the following note to Spanish: The launch is tomorrow.", + "请把最新的客户邮件整理成简短摘要。", + "請把最新的客戶郵件整理成簡短摘要。", + "OK?", + "Review este draft and keep the product names unchanged.", + ], +) +def test_request_language_harness_preserves_the_whole_request_without_detection( + user_request: str, +) -> None: + harness = request_only_language_harness(user_request) + + assert json.dumps(user_request, ensure_ascii=False) in harness + assert "Request-only response language harness" in harness + assert "explicit and implicit requests" in harness + assert "too short, mixed-language, or depends on conversation context" in harness + assert "Output language:" not in harness + + +def test_root_language_harness_uses_only_the_user_authored_request() -> None: + system_context = _polluted_context()._system_context() + harness = system_context.split("Request-only response language harness:\n", 1)[1] + + assert json.dumps(ENGLISH_REQUEST) in harness + assert "Gerard Santos" not in harness + assert "example.es" not in harness + assert "bandeja de entrada" not in harness + + +def test_dag_language_consumers_receive_the_same_user_authored_request() -> None: + context = _polluted_context() + plan_payload = json.loads( + LLMPlanGenerator()._build_prompt( + PlanGenerationRequest( + context=context, + execution_id=context.execution_id, + available_tool_names=[], + ) + ) + ) + completion_payload = json.loads( + DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ + "content" + ] + ) + + assert plan_payload["latest_user_request"] == ENGLISH_REQUEST + assert json.dumps(ENGLISH_REQUEST) in plan_payload["output_language_policy"] + assert completion_payload["user_authored_language_request"] == ENGLISH_REQUEST + assert json.dumps(ENGLISH_REQUEST) in completion_payload["output_language_policy"] + assert "Gerard Santos" not in completion_payload["output_language_policy"] + + +def test_caller_pinned_language_remains_the_only_hard_authority() -> None: + context = _polluted_context() + context.metadata["request_context"] = {OUTPUT_LANGUAGE_METADATA_KEY: "French"} + context.metadata[OUTPUT_LANGUAGE_METADATA_KEY] = "French" + + system_context = context._system_context() + completion_payload = json.loads( + DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ + "content" + ] + ) + + assert "Output language: French" in system_context + assert "Request-only response language harness" not in system_context + assert "Output language: French" in completion_payload["output_language_policy"] + assert completion_payload["user_authored_language_request"] == "" + + +def test_final_answer_schemas_follow_the_shared_language_guidance() -> None: + react_schema = ReActPattern()._final_answer_tool_schema() + react_function = react_schema["function"] + auto_function = AutoPattern()._decision_tool_schema()["function"] + + assert "authoritative output language guidance" in react_function["description"] + assert "connector metadata" in react_function["description"] + assert "authoritative output language guidance" in auto_function["description"] + assert "connector metadata" in auto_function["description"] From c67b55e9ed45849f87c6ddf64994988332c136b9 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 1 Sep 2026 12:42:16 +0800 Subject: [PATCH 02/10] chore(tests): apply isort formatting --- tests/core/agent/test_request_language_harness.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core/agent/test_request_language_harness.py b/tests/core/agent/test_request_language_harness.py index 010b9d06b2..66afa5f26d 100644 --- a/tests/core/agent/test_request_language_harness.py +++ b/tests/core/agent/test_request_language_harness.py @@ -17,7 +17,6 @@ ) from xagent.core.agent.pattern.react.react import ReActPattern - ENGLISH_REQUEST = "Summarize the latest customer email and draft a concise reply." POLLUTED_EXECUTION_REQUEST = ( f"{ENGLISH_REQUEST}\n\n" From 37cde4fa7f9f7cbde1dfa9b651a0f3a18cd235e8 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 1 Sep 2026 14:23:32 +0800 Subject: [PATCH 03/10] test(agent): update language prompt assertions --- tests/core/agent/test_auto.py | 5 +++-- tests/core/agent/test_context.py | 4 +--- tests/core/agent/test_dag.py | 14 +++++++++----- tests/core/agent/test_react.py | 7 +++++-- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/core/agent/test_auto.py b/tests/core/agent/test_auto.py index 65b5297a76..8aaad754ae 100644 --- a/tests/core/agent/test_auto.py +++ b/tests/core/agent/test_auto.py @@ -857,10 +857,11 @@ async def test_auto_pattern_final_answer_completes_without_child_pattern() -> No assert runtime.last_checkpoint is not None assert runtime.last_checkpoint["pattern"] == "AutoPattern" assert ( - "same natural language as the current user request" + "authoritative output language guidance in the system context" in tool_schema["description"] ) - assert "tool results, source documents" in answer_schema["description"] + assert "connector metadata" in answer_schema["description"] + assert request_only_language_harness("hi") in llm.calls[0]["messages"][0]["content"] @pytest.mark.asyncio diff --git a/tests/core/agent/test_context.py b/tests/core/agent/test_context.py index f62db96167..6a1f4c983b 100644 --- a/tests/core/agent/test_context.py +++ b/tests/core/agent/test_context.py @@ -698,9 +698,7 @@ def test_get_messages_for_llm_uses_compact_dag_output_language_policy() -> None: assert "Output language: English" in system_content # A caller-pinned language is authoritative; the soft request quote would # contradict it. - assert "Current user request, quoted for response language only:" not in ( - system_content - ) + assert "Request-only response language harness:" not in system_content assert "Create two posters." not in system_content assert "Only execute the current DAG step" in system_content assert [message["role"] for message in result].count("system") == 1 diff --git a/tests/core/agent/test_dag.py b/tests/core/agent/test_dag.py index 544c769d12..2c617aa199 100644 --- a/tests/core/agent/test_dag.py +++ b/tests/core/agent/test_dag.py @@ -623,14 +623,18 @@ async def test_dag_pattern_streams_overall_completion_not_step_result() -> None: assert has_tool(llm.stream_calls[1], DAG_COMPLETION_TOOL_NAME) completion_messages = llm.stream_calls[1]["messages"] assert ( - "same natural language as the output language policy" + "The final answer must follow the output language policy" in completion_messages[0]["content"] ) completion_payload = json.loads(completion_messages[-1]["content"]) - assert "output_language_policy" in completion_payload + request = "Answer through DAG" + assert completion_payload["user_authored_language_request"] == request + assert completion_payload["output_language_policy"] == ( + request_only_language_harness(request) + ) completion_tool = llm.stream_calls[1]["tools"][0]["function"] answer_schema = completion_tool["parameters"]["properties"]["answer"] - assert "tool results, source documents" in answer_schema["description"] + assert "connector metadata" in answer_schema["description"] assert [event["type"] for event in outbound.events] == [ "final_answer_start", "final_answer_delta", @@ -1065,7 +1069,7 @@ async def test_dag_step_appends_current_step_boundary_after_parent_context() -> assert "Overall user goal is background context only" in messages[0]["content"] assert "Output language policy" in messages[0]["content"] assert ( - "Current user request, quoted for response language only:" + request_only_language_harness("Extract highlights and generate two posters.") in messages[0]["content"] ) assert "Extract highlights and generate two posters." in messages[0]["content"] @@ -5289,7 +5293,7 @@ async def run(self, *, context: Any, **kwargs: Any) -> dict[str, Any]: if message.metadata.get("kind") == "dag_step_instruction" ) assert "Output language: Simplified Chinese" not in instruction - assert "Use the same natural language as the current user request" in instruction + assert request_only_language_harness("") in instruction _FILE_REFERENCE_BLOCK = ( diff --git a/tests/core/agent/test_react.py b/tests/core/agent/test_react.py index 073573c0d3..7a0ad8719f 100644 --- a/tests/core/agent/test_react.py +++ b/tests/core/agent/test_react.py @@ -23,6 +23,7 @@ ToolCallRecord, ) from xagent.core.agent.context.execution import CLOCK_TIMEZONE_METADATA_KEY +from xagent.core.agent.language import request_only_language_harness from xagent.core.agent.pattern.final_answer_stream import ReActFinalAnswerStreamer from xagent.core.agent.pattern.react.react import ( _INTERACTION_TRIM_CHARS, @@ -4047,9 +4048,11 @@ async def test_react_pattern_reserves_control_tool_names_in_schema() -> None: if schema["function"]["name"] == "final_answer" )["function"] assert ( - "same natural language as the current user request" + "authoritative output language guidance in the system context" in final_answer_schema["description"] ) + assert "connector metadata" in final_answer_schema["description"] + assert request_only_language_harness("Say hi") in system_prompt assert ( "Call this tool alone: never place it in the same response as any " "other tool call" in final_answer_schema["description"] @@ -4069,7 +4072,7 @@ async def test_react_pattern_reserves_control_tool_names_in_schema() -> None: assert "generic Chinese" in response_language_schema["description"] answer_schema = final_answer_schema["parameters"]["properties"]["answer"] assert "response_language" in answer_schema["description"] - assert "tool results, source documents" in answer_schema["description"] + assert "connector metadata" in answer_schema["description"] assert "## FINAL DELIVERABLE FILE REFERENCES" not in answer_schema["description"] assert "exact markdown_link" in answer_schema["description"] assert "get_workspace_output_files" not in answer_schema["description"] From 47e1257d4a7cc1117b77103085f5bb737362f47c Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 1 Sep 2026 16:00:10 +0800 Subject: [PATCH 04/10] fix(agent): harden request language boundaries --- src/xagent/core/agent/context/enrichment.py | 32 ++-- src/xagent/core/agent/context/execution.py | 17 ++- src/xagent/core/agent/language.py | 24 ++- tests/core/agent/test_dag.py | 6 +- tests/core/agent/test_output_language_seam.py | 26 ++-- .../agent/test_request_language_harness.py | 138 +++++++++++++++++- 6 files changed, 209 insertions(+), 34 deletions(-) diff --git a/src/xagent/core/agent/context/enrichment.py b/src/xagent/core/agent/context/enrichment.py index 441a43311f..bb0c1dd630 100644 --- a/src/xagent/core/agent/context/enrichment.py +++ b/src/xagent/core/agent/context/enrichment.py @@ -110,23 +110,35 @@ def build_skill_context(skill: dict[str, Any]) -> str: return f"## Available Skill: {name}\n\n{content}".strip() +def display_message_override(metadata: Any) -> str | None: + """Return a supported display-message override, including an empty one. + + Missing keys and legacy non-string values keep the execution-content + fallback. A present string is authoritative after trimming, so file-only + turns with an intentionally blank display message do not expose augmented + connector or attachment text as user-authored language evidence. + """ + if not isinstance(metadata, dict) or "display_message" not in metadata: + return None + display = metadata["display_message"] + if not isinstance(display, str): + return None + return display.strip() + + def latest_user_text(context: Any, *, prefer_display: bool = False) -> str: """Return the latest user turn's text. - ``prefer_display`` returns what the user actually typed instead of the - runtime-augmented execution prompt; language anchors must use it, work - anchors must not. + ``prefer_display`` returns a present string ``display_message`` (including + an intentionally empty one) instead of the runtime-augmented execution + prompt. Missing and legacy non-string values fall back to content. Language + anchors must prefer display text; work anchors must not. """ for message in reversed(getattr(context, "messages", []) or []): if getattr(message, "role", None) == "user": if prefer_display: - metadata = getattr(message, "metadata", None) - display = ( - metadata.get("display_message") - if isinstance(metadata, dict) - else None - ) - if isinstance(display, str) and display.strip(): + display = display_message_override(getattr(message, "metadata", None)) + if display is not None: return display return str(getattr(message, "content", "") or "") task = context.metadata.get("task") if hasattr(context, "metadata") else None diff --git a/src/xagent/core/agent/context/execution.py b/src/xagent/core/agent/context/execution.py index dc18a5cdfd..b9d9b0a1c4 100644 --- a/src/xagent/core/agent/context/execution.py +++ b/src/xagent/core/agent/context/execution.py @@ -42,6 +42,7 @@ IMAGE_EDIT_UNAVAILABLE_METADATA_KEY, MEMORY_CONTEXT_METADATA_KEY, SKILL_CONTEXT_METADATA_KEY, + display_message_override, ) from .memory_tool import MEMORY_TOOLS_METADATA_KEY from .message import LLMCallRecord, Message @@ -518,22 +519,24 @@ def _current_time_context(self) -> str: def _current_user_request_text(self, *, prefer_display: bool = False) -> str: """Return the current request text. - ``prefer_display`` yields the user-typed message instead of the - execution prompt, whose appended file-reference block is fixed English - and would otherwise decide the language of a short foreign request. + ``prefer_display`` yields a present string ``display_message``, including + an intentionally empty one, instead of the execution prompt. Missing and + legacy non-string values fall back to content. This keeps appended file + or connector context from deciding the response language. """ for message in reversed(self.messages): if message.hidden or message.role != "user": continue - if message.metadata.get("response_to_waiting_for_user"): + metadata = message.metadata if isinstance(message.metadata, dict) else {} + if metadata.get("response_to_waiting_for_user"): continue # A DAG child context copies the root messages and then appends step # scaffolding; only the root request may anchor the response language. - if message.metadata.get("dag_step_id"): + if metadata.get("dag_step_id"): continue if prefer_display: - display = str(message.metadata.get("display_message") or "").strip() - if display: + display = display_message_override(metadata) + if display is not None: return display content = str(message.content or "").strip() if content: diff --git a/src/xagent/core/agent/language.py b/src/xagent/core/agent/language.py index cf41b3fa48..c8d96e0237 100644 --- a/src/xagent/core/agent/language.py +++ b/src/xagent/core/agent/language.py @@ -490,8 +490,6 @@ def request_only_language_harness(request: str) -> str: competing with the user's request. """ request = request.strip() - if not request: - return response_language_rules() return ( "Request-only response language harness:\n" "User-authored request (JSON string):\n" @@ -510,6 +508,24 @@ def request_only_language_harness(request: str) -> str: ) +def _structured_request_language_policy(request_field: str) -> str: + """Reference one structured request field without duplicating its value.""" + subject = f"the `{request_field}` field" + return ( + "Request-only response language policy: Decide the target language of " + f"user-facing prose from {subject} alone. Honor explicit and implicit " + "requests to translate, rewrite, or answer in another language. Names, " + "email addresses, connector metadata, quoted source content, memory, tool " + "results, examples, and earlier turns are not language evidence. If the " + "field is empty, too short, mixed-language, or depends on conversation " + "context, resolve its meaning from the conversation without guessing from " + "auxiliary context. For Chinese, preserve Simplified Chinese versus " + "Traditional Chinese from the field. This policy controls language only; " + "it does not replace or narrow the executable request.\n\n" + f"{response_language_rules(subject=subject)}" + ) + + def final_answer_language_rule( *, subject: str = "authoritative output language guidance in the system context" ) -> str: @@ -598,4 +614,8 @@ def output_language_directives( return request_only_language_harness(request) if language: return output_language_policy(language) + if section == "plan_payload": + return _structured_request_language_policy("latest_user_request") + if section == "completion_assessment": + return _structured_request_language_policy("user_authored_language_request") return request_only_language_harness(request) diff --git a/tests/core/agent/test_dag.py b/tests/core/agent/test_dag.py index 2c617aa199..1461e6f397 100644 --- a/tests/core/agent/test_dag.py +++ b/tests/core/agent/test_dag.py @@ -629,9 +629,11 @@ async def test_dag_pattern_streams_overall_completion_not_step_result() -> None: completion_payload = json.loads(completion_messages[-1]["content"]) request = "Answer through DAG" assert completion_payload["user_authored_language_request"] == request - assert completion_payload["output_language_policy"] == ( - request_only_language_harness(request) + assert ( + "`user_authored_language_request` field" + in completion_payload["output_language_policy"] ) + assert request not in completion_payload["output_language_policy"] completion_tool = llm.stream_calls[1]["tools"][0]["function"] answer_schema = completion_tool["parameters"]["properties"]["answer"] assert "connector metadata" in answer_schema["description"] diff --git a/tests/core/agent/test_output_language_seam.py b/tests/core/agent/test_output_language_seam.py index 4943413712..499350f062 100644 --- a/tests/core/agent/test_output_language_seam.py +++ b/tests/core/agent/test_output_language_seam.py @@ -11,7 +11,6 @@ output_language_directives, output_language_policy, request_only_language_harness, - response_language_rules, ) from xagent.core.agent.pattern.dag.dag import DAGPattern from xagent.core.agent.pattern.dag.plan_generator import ( @@ -91,10 +90,9 @@ def test_output_language_directives_render_each_section_verbatim() -> None: assert output_language_directives("Japanese", section="root_system_context") == ( f"Output language policy:\n{output_language_policy('Japanese')}" ) - assert ( - output_language_directives("", section="root_system_context") - == response_language_rules() - ) + assert output_language_directives( + "", section="root_system_context" + ) == request_only_language_harness("") assert ( output_language_directives("Japanese", section="dag_step_scope") == output_language_policy("Japanese").strip() @@ -116,9 +114,15 @@ def test_output_language_directives_render_each_section_verbatim() -> None: assert output_language_directives( "Japanese", section=section ) == output_language_policy("Japanese") - assert output_language_directives("", section=section) == ( - request_only_language_harness("") - ) + assert output_language_directives( + "", section="dag_step_instruction" + ) == request_only_language_harness("") + assert "`user_authored_language_request` field" in output_language_directives( + "", section="completion_assessment" + ) + assert "`latest_user_request` field" in output_language_directives( + "", section="plan_payload" + ) def test_every_consumer_renders_the_resolved_language() -> None: @@ -157,8 +161,10 @@ def test_every_consumer_falls_back_when_no_language_is_recorded() -> None: assert output_language_directives( "", section="dag_step_instruction" ) in _step_instruction(None) - assert _completion_policy(None) == request_only_language_harness(request) - assert _plan_payload_policy(None) == request_only_language_harness(request) + assert "`user_authored_language_request` field" in _completion_policy(None) + assert request not in _completion_policy(None) + assert "`latest_user_request` field" in _plan_payload_policy(None) + assert request not in _plan_payload_policy(None) def test_consumers_normalize_an_aliased_language_label() -> None: diff --git a/tests/core/agent/test_request_language_harness.py b/tests/core/agent/test_request_language_harness.py index 66afa5f26d..69bd54201e 100644 --- a/tests/core/agent/test_request_language_harness.py +++ b/tests/core/agent/test_request_language_harness.py @@ -1,10 +1,12 @@ from __future__ import annotations import json +from types import SimpleNamespace import pytest from xagent.core.agent.context import ExecutionContext +from xagent.core.agent.context.enrichment import latest_user_text from xagent.core.agent.language import ( OUTPUT_LANGUAGE_METADATA_KEY, request_only_language_harness, @@ -21,7 +23,8 @@ POLLUTED_EXECUTION_REQUEST = ( f"{ENGLISH_REQUEST}\n\n" "[From: Gerard Santos ]\n" - "Connector context: bandeja de entrada, correo electrónico, responder." + "Connector context: bandeja de entrada, correo electrónico, responder.\n" + "Attached file(s): correo-del-cliente.pdf" ) @@ -66,6 +69,78 @@ def test_root_language_harness_uses_only_the_user_authored_request() -> None: assert "bandeja de entrada" not in harness +@pytest.mark.parametrize("display_message", ["", " \n\t"]) +def test_blank_display_message_is_an_authoritative_empty_language_request( + display_message: str, +) -> None: + context = ExecutionContext(execution_id="blank-display-language") + context.metadata["task"] = "Responder al correo adjunto." + context.add_user_message( + POLLUTED_EXECUTION_REQUEST, + metadata={"display_message": display_message}, + ) + + system_context = context._system_context() + plan_payload = json.loads( + LLMPlanGenerator()._build_prompt( + PlanGenerationRequest( + context=context, + execution_id=context.execution_id, + available_tool_names=[], + ) + ) + ) + completion_payload = json.loads( + DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ + "content" + ] + ) + + assert context._current_user_request_text(prefer_display=True) == "" + assert latest_user_text(context, prefer_display=True) == "" + assert request_only_language_harness("") in system_context + assert request_only_language_harness(POLLUTED_EXECUTION_REQUEST) not in ( + system_context + ) + assert plan_payload["latest_user_request"] == "" + assert "`latest_user_request` field" in plan_payload["output_language_policy"] + assert "Gerard Santos" not in plan_payload["output_language_policy"] + assert completion_payload["user_authored_language_request"] == "" + assert ( + "`user_authored_language_request` field" + in completion_payload["output_language_policy"] + ) + assert "Gerard Santos" not in completion_payload["output_language_policy"] + + +@pytest.mark.parametrize( + "metadata", + [ + {}, + {"display_message": 42}, + None, + ], +) +def test_unsupported_display_metadata_preserves_execution_content_fallback( + metadata: object, +) -> None: + context = ExecutionContext(execution_id="legacy-display-language") + context.messages.append( + SimpleNamespace( + role="user", + hidden=False, + content=POLLUTED_EXECUTION_REQUEST, + metadata=metadata, + ) + ) + + assert ( + context._current_user_request_text(prefer_display=True) + == POLLUTED_EXECUTION_REQUEST + ) + assert latest_user_text(context, prefer_display=True) == POLLUTED_EXECUTION_REQUEST + + def test_dag_language_consumers_receive_the_same_user_authored_request() -> None: context = _polluted_context() plan_payload = json.loads( @@ -84,18 +159,70 @@ def test_dag_language_consumers_receive_the_same_user_authored_request() -> None ) assert plan_payload["latest_user_request"] == ENGLISH_REQUEST - assert json.dumps(ENGLISH_REQUEST) in plan_payload["output_language_policy"] + assert "`latest_user_request` field" in plan_payload["output_language_policy"] + assert ENGLISH_REQUEST not in plan_payload["output_language_policy"] assert completion_payload["user_authored_language_request"] == ENGLISH_REQUEST - assert json.dumps(ENGLISH_REQUEST) in completion_payload["output_language_policy"] + assert ( + "`user_authored_language_request` field" + in completion_payload["output_language_policy"] + ) + assert ENGLISH_REQUEST not in completion_payload["output_language_policy"] assert "Gerard Santos" not in completion_payload["output_language_policy"] +def test_structured_language_payloads_include_a_large_request_exactly_once() -> None: + request = "LANGUAGE_SENTINEL_BEGIN_" + "背景" * 8_000 + "_LANGUAGE_SENTINEL_END" + context = ExecutionContext(execution_id="large-request-language") + context.add_user_message( + "[Connector execution context in Spanish: archivo adjunto]", + metadata={"display_message": request}, + ) + + system_context = context._system_context() + plan_payload = json.loads( + LLMPlanGenerator()._build_prompt( + PlanGenerationRequest( + context=context, + execution_id=context.execution_id, + available_tool_names=[], + ) + ) + ) + completion_payload = json.loads( + DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ + "content" + ] + ) + + assert system_context.count(request) == 1 + assert plan_payload["latest_user_request"] == request + assert json.dumps(plan_payload, ensure_ascii=False).count(request) == 1 + assert request not in plan_payload["output_language_policy"] + assert "`latest_user_request` field" in plan_payload["output_language_policy"] + assert completion_payload["user_authored_language_request"] == request + assert json.dumps(completion_payload, ensure_ascii=False).count(request) == 1 + assert request not in completion_payload["output_language_policy"] + assert ( + "`user_authored_language_request` field" + in completion_payload["output_language_policy"] + ) + + def test_caller_pinned_language_remains_the_only_hard_authority() -> None: context = _polluted_context() context.metadata["request_context"] = {OUTPUT_LANGUAGE_METADATA_KEY: "French"} context.metadata[OUTPUT_LANGUAGE_METADATA_KEY] = "French" system_context = context._system_context() + plan_payload = json.loads( + LLMPlanGenerator()._build_prompt( + PlanGenerationRequest( + context=context, + execution_id=context.execution_id, + available_tool_names=[], + ) + ) + ) completion_payload = json.loads( DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ "content" @@ -104,6 +231,11 @@ def test_caller_pinned_language_remains_the_only_hard_authority() -> None: assert "Output language: French" in system_context assert "Request-only response language harness" not in system_context + assert "Output language: French" in plan_payload["output_language_policy"] + assert ( + "Request-only response language policy" + not in plan_payload["output_language_policy"] + ) assert "Output language: French" in completion_payload["output_language_policy"] assert completion_payload["user_authored_language_request"] == "" From 92f9611db01499b7327dc9415ae003581233d96f Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 1 Sep 2026 17:03:05 +0800 Subject: [PATCH 05/10] fix(agent): close language prompt review gaps --- src/xagent/core/agent/context/enrichment.py | 14 +- src/xagent/core/agent/context/execution.py | 9 +- src/xagent/core/agent/language.py | 96 +++++--- src/xagent/core/agent/pattern/dag/dag.py | 6 +- tests/core/agent/test_auto.py | 21 +- tests/core/agent/test_context.py | 6 +- tests/core/agent/test_dag.py | 19 +- tests/core/agent/test_output_language_seam.py | 32 ++- tests/core/agent/test_react.py | 6 +- .../agent/test_request_language_harness.py | 210 ++++++++++++------ tests/core/agent/test_runner.py | 24 ++ 11 files changed, 305 insertions(+), 138 deletions(-) diff --git a/src/xagent/core/agent/context/enrichment.py b/src/xagent/core/agent/context/enrichment.py index bb0c1dd630..0d33d76e5c 100644 --- a/src/xagent/core/agent/context/enrichment.py +++ b/src/xagent/core/agent/context/enrichment.py @@ -113,10 +113,11 @@ def build_skill_context(skill: dict[str, Any]) -> str: def display_message_override(metadata: Any) -> str | None: """Return a supported display-message override, including an empty one. - Missing keys and legacy non-string values keep the execution-content - fallback. A present string is authoritative after trimming, so file-only - turns with an intentionally blank display message do not expose augmented - connector or attachment text as user-authored language evidence. + Missing keys and non-string values in directly constructed or restored + contexts keep the execution-content fallback. The production runner + normalizes a present non-string value to an authoritative empty string before + this helper. Any present string is authoritative after trimming, so file-only + turns do not expose augmented connector or attachment text as language evidence. """ if not isinstance(metadata, dict) or "display_message" not in metadata: return None @@ -131,8 +132,9 @@ def latest_user_text(context: Any, *, prefer_display: bool = False) -> str: ``prefer_display`` returns a present string ``display_message`` (including an intentionally empty one) instead of the runtime-augmented execution - prompt. Missing and legacy non-string values fall back to content. Language - anchors must prefer display text; work anchors must not. + prompt. Missing values, plus non-string values in direct/restored contexts, + fall back to content. Language anchors must prefer display text; work anchors + must not. """ for message in reversed(getattr(context, "messages", []) or []): if getattr(message, "role", None) == "user": diff --git a/src/xagent/core/agent/context/execution.py b/src/xagent/core/agent/context/execution.py index b9d9b0a1c4..9dce8abdcd 100644 --- a/src/xagent/core/agent/context/execution.py +++ b/src/xagent/core/agent/context/execution.py @@ -549,10 +549,15 @@ def _system_context(self) -> str: current_task = self._current_user_request_text() output_language = effective_output_language(self) if current_task and not dag_step_id: + language_request = self._current_user_request_text(prefer_display=True) language_directives = output_language_directives( output_language, - section="root_system_context", - request=self._current_user_request_text(prefer_display=True), + section=( + "root_existing_request" + if not output_language and language_request == current_task + else "root_system_context" + ), + request=language_request, ) parts.append( "Current user request:\n" diff --git a/src/xagent/core/agent/language.py b/src/xagent/core/agent/language.py index c8d96e0237..13894418ad 100644 --- a/src/xagent/core/agent/language.py +++ b/src/xagent/core/agent/language.py @@ -494,50 +494,75 @@ def request_only_language_harness(request: str) -> str: "Request-only response language harness:\n" "User-authored request (JSON string):\n" f"{json.dumps(request, ensure_ascii=False)}\n\n" - "Decide the target language of user-facing prose from the user-authored " - "request above alone. Honor explicit and implicit requests to translate, " - "rewrite, or answer in another language. A person's name, email address, " - "connector metadata, quoted source content, memory, tool result, example, " - "or earlier turn is not evidence of the target language. For Chinese, " - "preserve Simplified Chinese versus Traditional Chinese from the request. " - "If the request is too short, mixed-language, or depends on conversation " - "context to determine a target language, resolve its meaning from the " - "conversation without guessing from auxiliary context. This quote controls " + f"{_soft_request_language_guidance(subject='user-authored request above', empty_subject='the quoted request', boundary='quote')}" + ) + + +def _soft_request_language_guidance( + *, subject: str, empty_subject: str, boundary: str +) -> str: + """Render shared soft-authority prose without carrying a request value.""" + return ( + f"Decide the target language of user-facing prose from the {subject} alone. " + "Honor explicit and implicit requests to translate, rewrite, or answer in " + "another language. Names, email addresses, connector metadata, quoted " + "source content, memory, tool results, examples, and earlier turns are not " + "language evidence. " + f"If {empty_subject} is empty, too short, mixed-language, or depends on " + "conversation context, resolve its meaning from the conversation without " + "guessing from auxiliary context. For Chinese, preserve Simplified Chinese " + f"versus Traditional Chinese from the {subject}. This {boundary} controls " "language only; it does not replace or narrow the executable request.\n\n" - f"{response_language_rules(subject='user-authored request above')}" + f"{response_language_rules(subject=subject)}" ) def _structured_request_language_policy(request_field: str) -> str: """Reference one structured request field without duplicating its value.""" - subject = f"the `{request_field}` field" + subject = f"`{request_field}` field" return ( - "Request-only response language policy: Decide the target language of " - f"user-facing prose from {subject} alone. Honor explicit and implicit " - "requests to translate, rewrite, or answer in another language. Names, " - "email addresses, connector metadata, quoted source content, memory, tool " - "results, examples, and earlier turns are not language evidence. If the " - "field is empty, too short, mixed-language, or depends on conversation " - "context, resolve its meaning from the conversation without guessing from " - "auxiliary context. For Chinese, preserve Simplified Chinese versus " - "Traditional Chinese from the field. This policy controls language only; " - "it does not replace or narrow the executable request.\n\n" - f"{response_language_rules(subject=subject)}" + "Request-only response language policy: " + f"{_soft_request_language_guidance(subject=subject, empty_subject='the field', boundary='policy')}" ) -def final_answer_language_rule( - *, subject: str = "authoritative output language guidance in the system context" -) -> str: +def _root_request_language_policy() -> str: + """Reference the root request already rendered immediately above.""" + return ( + "Request-only response language policy: " + f"{_soft_request_language_guidance(subject='current user request above', empty_subject='request', boundary='policy')}" + ) + + +def _dag_step_instruction_language_policy() -> str: + """Point a DAG instruction at its existing system-context language anchor.""" + return ( + "Follow the authoritative request-language guidance already present in " + "the system context for all user-facing prose and persisted tool arguments. " + "Do not infer a different language from the current DAG step, dependency " + "results, tools, sources, connector metadata, memory, or examples." + ) + + +def final_answer_language_rule(*, subject: str | None = None) -> str: """Return a compact language rule for final-answer tool fields.""" + authority = ( + f"follow the {subject}." + if subject + else ( + "follow authoritative output language guidance in the system context " + "when it is present. Otherwise determine the target language from " + "user-authored request text and conversation context; if no such text " + "is available, preserve the language established by the conversation." + ) + ) return ( - f"The final answer must follow the {subject}. Tool results, source " - "documents, retrieved memories, examples, names, email addresses, " - "connector metadata, and earlier turns must not change that language. " - "When the guidance quotes a user-authored request, honor any explicit or " - "implicit request to translate, rewrite, or answer in another language. " - "For Chinese, preserve Simplified Chinese versus Traditional Chinese from " - "the request; do not collapse them into generic Chinese." + f"The final answer must {authority} Honor any explicit or implicit request " + "to translate, rewrite, or answer in another language. Tool results, source " + "documents, retrieved memories, examples, names, email addresses, connector " + "metadata, and earlier turns must not override that decision. For Chinese, " + "preserve Simplified Chinese versus Traditional Chinese from user-authored " + "text; do not collapse them into generic Chinese." ) @@ -574,6 +599,7 @@ def dag_step_language_rules(*, subject: str = "output language policy") -> str: OutputLanguageSection = Literal[ "root_system_context", + "root_existing_request", "dag_step_scope", "dag_step_rules", "dag_step_request_anchor", @@ -600,6 +626,10 @@ def output_language_directives( if language: return f"Output language policy:\n{output_language_policy(language)}" return request_only_language_harness(request) + if section == "root_existing_request": + if language: + return f"Output language policy:\n{output_language_policy(language)}" + return _root_request_language_policy() if section == "dag_step_scope": return output_language_policy(language).strip() if section == "dag_step_rules": @@ -614,6 +644,8 @@ def output_language_directives( return request_only_language_harness(request) if language: return output_language_policy(language) + if section == "dag_step_instruction": + return _dag_step_instruction_language_policy() if section == "plan_payload": return _structured_request_language_policy("latest_user_request") if section == "completion_assessment": diff --git a/src/xagent/core/agent/pattern/dag/dag.py b/src/xagent/core/agent/pattern/dag/dag.py index bd9d05256e..0309c1a821 100644 --- a/src/xagent/core/agent/pattern/dag/dag.py +++ b/src/xagent/core/agent/pattern/dag/dag.py @@ -1521,8 +1521,10 @@ def _completion_assessment_messages(self, context: Any) -> list[dict[str, Any]]: section="completion_assessment", request=language_request, ), - "user_authored_language_request": ( - "" if output_language else language_request + **( + {} + if output_language + else {"user_authored_language_request": language_request} ), "authoritative_user_requests": authoritative_user_requests, "messages": latest_messages, diff --git a/tests/core/agent/test_auto.py b/tests/core/agent/test_auto.py index 8aaad754ae..e57aefe9b1 100644 --- a/tests/core/agent/test_auto.py +++ b/tests/core/agent/test_auto.py @@ -25,7 +25,7 @@ OUTPUT_LANGUAGE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_PLAN, - request_only_language_harness, + output_language_directives, ) from xagent.core.agent.pattern.auto.auto import DECISION_TOOL_NAME, _AutoChildRuntime from xagent.core.model.chat.basic.router import RouterLLM @@ -861,7 +861,10 @@ async def test_auto_pattern_final_answer_completes_without_child_pattern() -> No in tool_schema["description"] ) assert "connector metadata" in answer_schema["description"] - assert request_only_language_harness("hi") in llm.calls[0]["messages"][0]["content"] + assert ( + output_language_directives("", section="root_existing_request") + in llm.calls[0]["messages"][0]["content"] + ) @pytest.mark.asyncio @@ -2295,10 +2298,7 @@ async def test_stale_memory_language_does_not_reach_child_as_hard_policy() -> No assert "Output language policy:" not in child_system assert "Summarize the quarterly revenue trend in one paragraph." in child_system assert ( - request_only_language_harness( - "Summarize the quarterly revenue trend in one paragraph." - ) - in child_system + output_language_directives("", section="root_existing_request") in child_system ) @@ -2325,15 +2325,18 @@ async def test_direct_final_answer_allows_an_explicit_target_language() -> None: assert result["success"] is True assert result["output"] == "La capitale de l'Italie est Rome." assert OUTPUT_LANGUAGE_METADATA_KEY not in context.metadata - target_rule = "honor any explicit or implicit request to translate" + target_rule = "Honor any explicit or implicit request to translate" tool_schema = llm.calls[0]["tools"][0]["function"] assert target_rule in tool_schema["description"] assert ( target_rule in tool_schema["parameters"]["properties"]["answer"]["description"] ) system_content = context.get_messages_for_llm()[0]["content"] - assert request in system_content - assert request_only_language_harness(request) in system_content + assert system_content.count(request) == 1 + assert ( + output_language_directives("", section="root_existing_request") + in system_content + ) class RoutedDecisionLLM: diff --git a/tests/core/agent/test_context.py b/tests/core/agent/test_context.py index 6a1f4c983b..c8dfc8a722 100644 --- a/tests/core/agent/test_context.py +++ b/tests/core/agent/test_context.py @@ -189,7 +189,7 @@ def test_system_context_preserves_current_request_language_over_memory() -> None assert "Can you analyze this GitHub project?" in system_message assert "Response language rules" in system_message assert ( - "Use the same natural language as the user-authored request above" + "Use the same natural language as the current user request above" in system_message ) assert "Do not let retrieved memories" in system_message @@ -745,9 +745,9 @@ def test_dag_step_language_quote_uses_the_typed_message() -> None: ) system_content = ctx.get_messages_for_llm()[0]["content"] - quote = system_content.split("Request-only response language harness:\n")[1] + quote = system_content.split("User-authored request (JSON string):\n", 1)[1] - assert typed in quote + assert quote.startswith(json.dumps(typed, ensure_ascii=False)) assert "Attached file(s)" not in quote diff --git a/tests/core/agent/test_dag.py b/tests/core/agent/test_dag.py index 1461e6f397..b01a42b79a 100644 --- a/tests/core/agent/test_dag.py +++ b/tests/core/agent/test_dag.py @@ -5202,6 +5202,9 @@ async def run(self, **kwargs: Any) -> dict[str, Any]: if message.metadata.get("kind") == "dag_step_instruction" ][0] assert "Output language: Simplified Chinese" not in step_instruction + assert "authoritative request-language guidance" in step_instruction + assert request_only_language_harness("") not in step_instruction + assert request not in step_instruction completion_payload = json.loads(llm.seen_messages[-1][-1]["content"]) completion_policy = completion_payload["output_language_policy"] assert "Output language: Simplified Chinese" not in completion_policy @@ -5252,13 +5255,21 @@ async def test_restored_dag_step_instruction_drops_stale_language_policy( step = PlanStep(id="write", task="Write the summary") pattern = DAGPattern(lambda **_: build_plan(step)) pattern.plan = build_plan(step) + request = "Summarize the release notes." legacy_root = ExecutionContext(execution_id="dag-restored-language-legacy") legacy_root.metadata[OUTPUT_LANGUAGE_METADATA_KEY] = "Simplified Chinese" + legacy_root.add_user_message(request) stale_instruction = pattern._step_instruction(root_context=legacy_root, step=step) assert "Output language: Simplified Chinese" in stale_instruction - child_context = ExecutionContext(execution_id="dag-restored-language:write") + child_context = legacy_root.create_child_context( + metadata={ + "dag_step_id": "write", + "dag_step_name": step.task, + "dag_step_description": step.task, + } + ) child_context.add_user_message( stale_instruction, metadata={"kind": "dag_step_instruction", "dag_step_id": "write"}, @@ -5279,7 +5290,7 @@ async def run(self, *, context: Any, **kwargs: Any) -> dict[str, Any]: monkeypatch.setattr(dag_module, "ReActPattern", CapturingReActPattern) root_context = ExecutionContext(execution_id="dag-restored-language") - root_context.add_user_message("Summarize the release notes.") + root_context.add_user_message(request) await pattern._execute_step_impl( step=step, root_context=root_context, @@ -5295,7 +5306,9 @@ async def run(self, *, context: Any, **kwargs: Any) -> dict[str, Any]: if message.metadata.get("kind") == "dag_step_instruction" ) assert "Output language: Simplified Chinese" not in instruction - assert request_only_language_harness("") in instruction + assert "authoritative request-language guidance" in instruction + assert request_only_language_harness("") not in instruction + assert request not in instruction _FILE_REFERENCE_BLOCK = ( diff --git a/tests/core/agent/test_output_language_seam.py b/tests/core/agent/test_output_language_seam.py index 499350f062..2a83faadb2 100644 --- a/tests/core/agent/test_output_language_seam.py +++ b/tests/core/agent/test_output_language_seam.py @@ -93,6 +93,9 @@ def test_output_language_directives_render_each_section_verbatim() -> None: assert output_language_directives( "", section="root_system_context" ) == request_only_language_harness("") + assert "current user request above" in output_language_directives( + "", section="root_existing_request" + ) assert ( output_language_directives("Japanese", section="dag_step_scope") == output_language_policy("Japanese").strip() @@ -114,15 +117,28 @@ def test_output_language_directives_render_each_section_verbatim() -> None: assert output_language_directives( "Japanese", section=section ) == output_language_policy("Japanese") - assert output_language_directives( + step_instruction_policy = output_language_directives( "", section="dag_step_instruction" - ) == request_only_language_harness("") - assert "`user_authored_language_request` field" in output_language_directives( - "", section="completion_assessment" ) - assert "`latest_user_request` field" in output_language_directives( - "", section="plan_payload" + assert step_instruction_policy == ( + "Follow the authoritative request-language guidance already present in " + "the system context for all user-facing prose and persisted tool arguments. " + "Do not infer a different language from the current DAG step, dependency " + "results, tools, sources, connector metadata, memory, or examples." + ) + assert request_only_language_harness("") not in step_instruction_policy + + completion_policy = output_language_directives("", section="completion_assessment") + plan_policy = output_language_directives("", section="plan_payload") + assert ( + "Use the same natural language as the `user_authored_language_request` " + "field for all user-facing prose." in completion_policy + ) + assert ( + "Use the same natural language as the `latest_user_request` field for all " + "user-facing prose." in plan_policy ) + assert "the the `" not in completion_policy + plan_policy def test_every_consumer_renders_the_resolved_language() -> None: @@ -151,7 +167,7 @@ def test_every_consumer_renders_the_resolved_language() -> None: def test_every_consumer_falls_back_when_no_language_is_recorded() -> None: request = "Summarize the repository" assert ( - output_language_directives("", section="root_system_context", request=request) + output_language_directives("", section="root_existing_request") in _root_context()._system_context() ) assert ( @@ -161,6 +177,8 @@ def test_every_consumer_falls_back_when_no_language_is_recorded() -> None: assert output_language_directives( "", section="dag_step_instruction" ) in _step_instruction(None) + assert request_only_language_harness("") not in _step_instruction(None) + assert request not in _step_instruction(None) assert "`user_authored_language_request` field" in _completion_policy(None) assert request not in _completion_policy(None) assert "`latest_user_request` field" in _plan_payload_policy(None) diff --git a/tests/core/agent/test_react.py b/tests/core/agent/test_react.py index 7a0ad8719f..6e9f6bd044 100644 --- a/tests/core/agent/test_react.py +++ b/tests/core/agent/test_react.py @@ -23,7 +23,7 @@ ToolCallRecord, ) from xagent.core.agent.context.execution import CLOCK_TIMEZONE_METADATA_KEY -from xagent.core.agent.language import request_only_language_harness +from xagent.core.agent.language import output_language_directives from xagent.core.agent.pattern.final_answer_stream import ReActFinalAnswerStreamer from xagent.core.agent.pattern.react.react import ( _INTERACTION_TRIM_CHARS, @@ -4052,7 +4052,9 @@ async def test_react_pattern_reserves_control_tool_names_in_schema() -> None: in final_answer_schema["description"] ) assert "connector metadata" in final_answer_schema["description"] - assert request_only_language_harness("Say hi") in system_prompt + assert ( + output_language_directives("", section="root_existing_request") in system_prompt + ) assert ( "Call this tool alone: never place it in the same response as any " "other tool call" in final_answer_schema["description"] diff --git a/tests/core/agent/test_request_language_harness.py b/tests/core/agent/test_request_language_harness.py index 69bd54201e..476680f1d9 100644 --- a/tests/core/agent/test_request_language_harness.py +++ b/tests/core/agent/test_request_language_harness.py @@ -9,6 +9,8 @@ from xagent.core.agent.context.enrichment import latest_user_text from xagent.core.agent.language import ( OUTPUT_LANGUAGE_METADATA_KEY, + final_answer_language_rule, + output_language_directives, request_only_language_harness, ) from xagent.core.agent.pattern.auto.auto import AutoPattern @@ -37,25 +39,57 @@ def _polluted_context() -> ExecutionContext: return context +def _language_surfaces( + context: ExecutionContext, +) -> tuple[str, dict[str, object], dict[str, object]]: + plan_payload = json.loads( + LLMPlanGenerator()._build_prompt( + PlanGenerationRequest( + context=context, + execution_id=context.execution_id, + available_tool_names=[], + ) + ) + ) + completion_payload = json.loads( + DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ + "content" + ] + ) + return context._system_context(), plan_payload, completion_payload + + @pytest.mark.parametrize( "user_request", [ - "Translate the following note to Spanish: The launch is tomorrow.", - "请把最新的客户邮件整理成简短摘要。", - "請把最新的客戶郵件整理成簡短摘要。", - "OK?", - "Review este draft and keep the product names unchanged.", + pytest.param( + "Translate the following note to Spanish: The launch is tomorrow.", + id="explicit-spanish-target", + ), + pytest.param("请把最新的客户邮件整理成简短摘要。", id="simplified-chinese"), + pytest.param("請把最新的客戶郵件整理成簡短摘要。", id="traditional-chinese"), + pytest.param("OK?", id="short-request"), + pytest.param( + 'Review este "draft"\\path and keep the product names unchanged.', + id="mixed-language-special-characters", + ), ], ) -def test_request_language_harness_preserves_the_whole_request_without_detection( +def test_request_language_harness_serializes_each_request_exactly( user_request: str, ) -> None: harness = request_only_language_harness(user_request) + quote = harness.split("User-authored request (JSON string):\n", 1)[1] + + assert quote.startswith(json.dumps(user_request, ensure_ascii=False)) + + +def test_request_language_harness_preserves_soft_authority_invariants() -> None: + harness = request_only_language_harness("Summarize this request.") - assert json.dumps(user_request, ensure_ascii=False) in harness assert "Request-only response language harness" in harness assert "explicit and implicit requests" in harness - assert "too short, mixed-language, or depends on conversation context" in harness + assert "empty, too short, mixed-language, or depends on conversation" in harness assert "Output language:" not in harness @@ -80,21 +114,7 @@ def test_blank_display_message_is_an_authoritative_empty_language_request( metadata={"display_message": display_message}, ) - system_context = context._system_context() - plan_payload = json.loads( - LLMPlanGenerator()._build_prompt( - PlanGenerationRequest( - context=context, - execution_id=context.execution_id, - available_tool_names=[], - ) - ) - ) - completion_payload = json.loads( - DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ - "content" - ] - ) + system_context, plan_payload, completion_payload = _language_surfaces(context) assert context._current_user_request_text(prefer_display=True) == "" assert latest_user_text(context, prefer_display=True) == "" @@ -143,20 +163,7 @@ def test_unsupported_display_metadata_preserves_execution_content_fallback( def test_dag_language_consumers_receive_the_same_user_authored_request() -> None: context = _polluted_context() - plan_payload = json.loads( - LLMPlanGenerator()._build_prompt( - PlanGenerationRequest( - context=context, - execution_id=context.execution_id, - available_tool_names=[], - ) - ) - ) - completion_payload = json.loads( - DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ - "content" - ] - ) + _, plan_payload, completion_payload = _language_surfaces(context) assert plan_payload["latest_user_request"] == ENGLISH_REQUEST assert "`latest_user_request` field" in plan_payload["output_language_policy"] @@ -178,21 +185,7 @@ def test_structured_language_payloads_include_a_large_request_exactly_once() -> metadata={"display_message": request}, ) - system_context = context._system_context() - plan_payload = json.loads( - LLMPlanGenerator()._build_prompt( - PlanGenerationRequest( - context=context, - execution_id=context.execution_id, - available_tool_names=[], - ) - ) - ) - completion_payload = json.loads( - DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ - "content" - ] - ) + system_context, plan_payload, completion_payload = _language_surfaces(context) assert system_context.count(request) == 1 assert plan_payload["latest_user_request"] == request @@ -208,26 +201,67 @@ def test_structured_language_payloads_include_a_large_request_exactly_once() -> ) +@pytest.mark.parametrize( + ("content", "metadata", "expected_policy", "quotes_request"), + [ + pytest.param( + "{request}", + {}, + output_language_directives("", section="root_existing_request"), + False, + id="missing-display-references-existing-request", + ), + pytest.param( + "[Connector context: correo adjunto]", + {"display_message": "{request}"}, + "{quoted_request}", + True, + id="different-display-keeps-isolated-quote", + ), + pytest.param( + "{request}\n[Connector context: correo adjunto]", + {"display_message": " \n\t"}, + request_only_language_harness(""), + False, + id="blank-display-keeps-empty-language-anchor", + ), + ], +) +def test_root_language_request_appears_once_for_every_display_shape( + content: str, + metadata: dict[str, object], + expected_policy: str, + quotes_request: bool, +) -> None: + request = "ROOT_SENTINEL_BEGIN_" + "背景" * 8_000 + "_ROOT_SENTINEL_END" + context = ExecutionContext(execution_id="root-request-cardinality") + context.add_user_message( + content.format(request=request), + metadata={ + key: value.format(request=request) if isinstance(value, str) else value + for key, value in metadata.items() + }, + ) + + system_context = context._system_context() + rendered_policy = expected_policy.format( + quoted_request=request_only_language_harness(request) + ) + + assert system_context.count(request) == 1 + assert rendered_policy in system_context + if quotes_request: + assert request_only_language_harness(request) in system_context + else: + assert request_only_language_harness(request) not in system_context + + def test_caller_pinned_language_remains_the_only_hard_authority() -> None: context = _polluted_context() context.metadata["request_context"] = {OUTPUT_LANGUAGE_METADATA_KEY: "French"} context.metadata[OUTPUT_LANGUAGE_METADATA_KEY] = "French" - system_context = context._system_context() - plan_payload = json.loads( - LLMPlanGenerator()._build_prompt( - PlanGenerationRequest( - context=context, - execution_id=context.execution_id, - available_tool_names=[], - ) - ) - ) - completion_payload = json.loads( - DAGPattern(lambda **_: None)._completion_assessment_messages(context)[1][ - "content" - ] - ) + system_context, plan_payload, completion_payload = _language_surfaces(context) assert "Output language: French" in system_context assert "Request-only response language harness" not in system_context @@ -237,7 +271,7 @@ def test_caller_pinned_language_remains_the_only_hard_authority() -> None: not in plan_payload["output_language_policy"] ) assert "Output language: French" in completion_payload["output_language_policy"] - assert completion_payload["user_authored_language_request"] == "" + assert "user_authored_language_request" not in completion_payload def test_final_answer_schemas_follow_the_shared_language_guidance() -> None: @@ -245,7 +279,39 @@ def test_final_answer_schemas_follow_the_shared_language_guidance() -> None: react_function = react_schema["function"] auto_function = AutoPattern()._decision_tool_schema()["function"] - assert "authoritative output language guidance" in react_function["description"] - assert "connector metadata" in react_function["description"] - assert "authoritative output language guidance" in auto_function["description"] - assert "connector metadata" in auto_function["description"] + rule = final_answer_language_rule() + assert rule.startswith( + "The final answer must follow authoritative output language guidance in " + "the system context when it is present. Otherwise determine the target " + "language from user-authored request text and conversation context" + ) + assert "connector metadata" in rule + for function in (react_function, auto_function): + answer_description = function["parameters"]["properties"]["answer"][ + "description" + ] + assert function["description"].endswith(rule) + assert answer_description.endswith(rule) + assert json.dumps(function, ensure_ascii=False).count(rule) == 2 + + +def test_final_answer_guidance_is_self_contained_without_a_root_request() -> None: + context = ExecutionContext( + execution_id="attachment-only-language", + metadata={"request_context": {"files": [{"name": "correo.pdf"}]}}, + ) + + root_system = context._system_context() + react_messages = ReActPattern()._messages_for_llm( + context, + has_tools=False, + force_final_answer=True, + ) + rule = final_answer_language_rule() + + assert "Request-only response language" not in root_system + assert rule in react_messages[0]["content"] + assert ( + AutoPattern()._decision_tool_schema()["function"]["description"].endswith(rule) + ) + assert "if no such text is available, preserve the language established" in rule diff --git a/tests/core/agent/test_runner.py b/tests/core/agent/test_runner.py index c02b4fa84b..a28eeeda72 100644 --- a/tests/core/agent/test_runner.py +++ b/tests/core/agent/test_runner.py @@ -19,6 +19,7 @@ CheckpointCorruptError, CheckpointUnavailableError, ) +from xagent.core.agent.context.enrichment import latest_user_text from xagent.core.agent.language import ( OUTPUT_LANGUAGE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_METADATA_KEY, @@ -1645,6 +1646,29 @@ async def test_runner_initial_user_message_preserves_display_metadata( assert user_event["data"]["turn_id"] == turn_id +@pytest.mark.parametrize("display_message", [None, 17], ids=["null", "non-string"]) +def test_runner_normalizes_unsupported_display_values_to_authoritative_empty( + tmp_path: Path, + display_message: object, +) -> None: + runner = AgentRunner( + agent=Agent(name="writer", patterns=[FakePattern({"success": True})]), + workspace_manager=FakeWorkspaceManager(tmp_path), + ) + context = ExecutionContext( + execution_id="exec-display-normalization", + metadata={"request_context": {"display_message": display_message}}, + ) + + metadata = runner._initial_user_message_metadata(context) + context.add_user_message( + "Connector context: responder en español.", metadata=metadata + ) + + assert metadata["display_message"] == "" + assert latest_user_text(context, prefer_display=True) == "" + + @pytest.mark.asyncio async def test_runner_attaches_uploaded_image_refs_to_initial_user_message( tmp_path: Path, From 0097114f788d9cdbef165e5a5967794f6f7bd364 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Wed, 2 Sep 2026 11:09:26 +0800 Subject: [PATCH 06/10] fix(agent): preserve request language provenance --- src/xagent/core/agent/context/enrichment.py | 86 ++++++++++- src/xagent/core/agent/context/execution.py | 60 ++++---- src/xagent/core/agent/language.py | 18 ++- src/xagent/core/agent/pattern/dag/dag.py | 29 +++- .../core/agent/pattern/dag/plan_generator.py | 8 +- tests/core/agent/test_auto.py | 14 +- tests/core/agent/test_context.py | 54 ++++--- tests/core/agent/test_dag.py | 21 ++- tests/core/agent/test_output_language_seam.py | 11 +- .../agent/test_request_language_harness.py | 135 +++++++++++++++++- 10 files changed, 358 insertions(+), 78 deletions(-) diff --git a/src/xagent/core/agent/context/enrichment.py b/src/xagent/core/agent/context/enrichment.py index 0d33d76e5c..f2c3b26ffd 100644 --- a/src/xagent/core/agent/context/enrichment.py +++ b/src/xagent/core/agent/context/enrichment.py @@ -2,7 +2,8 @@ import asyncio import logging -from typing import Any, cast +from dataclasses import dataclass +from typing import Any, Literal, cast from ...agent.trace import ( trace_memory_retrieve_end, @@ -21,6 +22,19 @@ IMAGE_EDIT_UNAVAILABLE_METADATA_KEY = "image_edit_unavailable" +DisplayMessageState = Literal["missing", "empty", "text"] + + +@dataclass(frozen=True) +class TopLevelUserRequest: + """One executable request and its presentation-only language boundary.""" + + execution_text: str + language_text: str + display_state: DisplayMessageState + has_pending_response: bool = False + + async def enrich_context_with_memory( *, context: Any, @@ -127,6 +141,76 @@ def display_message_override(metadata: Any) -> str | None: return display.strip() +def top_level_user_request(context: Any) -> TopLevelUserRequest: + """Return the latest independent request, excluding DAG and wait scaffolding. + + A present display string is authoritative for language even when empty. + Answers to pending agent questions remain conversational context, but they do + not replace the independent request. Prompt policy may still honor an explicit + language-change instruction in such an answer. + """ + has_pending_response = False + for message in reversed(getattr(context, "messages", []) or []): + if getattr(message, "role", None) != "user" or getattr( + message, "hidden", False + ): + continue + metadata = getattr(message, "metadata", None) + metadata = metadata if isinstance(metadata, dict) else {} + if metadata.get("response_to_waiting_for_user"): + has_pending_response = True + continue + if metadata.get("dag_step_id"): + continue + + execution_text = str(getattr(message, "content", "") or "").strip() + display_text = display_message_override(metadata) + if display_text is None: + if not execution_text: + continue + return TopLevelUserRequest( + execution_text=execution_text, + language_text=execution_text, + display_state="missing", + has_pending_response=has_pending_response, + ) + return TopLevelUserRequest( + execution_text=execution_text, + language_text=display_text, + display_state="text" if display_text else "empty", + has_pending_response=has_pending_response, + ) + + task = ( + context.metadata.get("task") + if isinstance(getattr(context, "metadata", None), dict) + else None + ) + task_text = str(task or "").strip() + return TopLevelUserRequest( + execution_text=task_text, + language_text=task_text, + display_state="missing", + has_pending_response=has_pending_response, + ) + + +def language_prompt_message(message: Any) -> dict[str, Any]: + """Serialize one prompt payload message without duplicating its content.""" + payload = { + "role": getattr(message, "role", None), + "content": getattr(message, "content", None), + } + metadata = getattr(message, "metadata", None) + if ( + payload["role"] == "user" + and isinstance(metadata, dict) + and metadata.get("response_to_waiting_for_user") + ): + payload["user_message_context"] = "pending_agent_question_response" + return payload + + def latest_user_text(context: Any, *, prefer_display: bool = False) -> str: """Return the latest user turn's text. diff --git a/src/xagent/core/agent/context/execution.py b/src/xagent/core/agent/context/execution.py index 9dce8abdcd..82a9ce3cb7 100644 --- a/src/xagent/core/agent/context/execution.py +++ b/src/xagent/core/agent/context/execution.py @@ -42,7 +42,7 @@ IMAGE_EDIT_UNAVAILABLE_METADATA_KEY, MEMORY_CONTEXT_METADATA_KEY, SKILL_CONTEXT_METADATA_KEY, - display_message_override, + top_level_user_request, ) from .memory_tool import MEMORY_TOOLS_METADATA_KEY from .message import LLMCallRecord, Message @@ -524,45 +524,29 @@ def _current_user_request_text(self, *, prefer_display: bool = False) -> str: legacy non-string values fall back to content. This keeps appended file or connector context from deciding the response language. """ - for message in reversed(self.messages): - if message.hidden or message.role != "user": - continue - metadata = message.metadata if isinstance(message.metadata, dict) else {} - if metadata.get("response_to_waiting_for_user"): - continue - # A DAG child context copies the root messages and then appends step - # scaffolding; only the root request may anchor the response language. - if metadata.get("dag_step_id"): - continue - if prefer_display: - display = display_message_override(metadata) - if display is not None: - return display - content = str(message.content or "").strip() - if content: - return content - return str(self.metadata.get("task") or "").strip() + request = top_level_user_request(self) + return request.language_text if prefer_display else request.execution_text def _system_context(self) -> str: parts = [self._current_time_context(), FILE_REF_MODEL_INSTRUCTIONS] dag_step_id = self.metadata.get("dag_step_id") - current_task = self._current_user_request_text() + request = top_level_user_request(self) + current_task = request.execution_text output_language = effective_output_language(self) - if current_task and not dag_step_id: - language_request = self._current_user_request_text(prefer_display=True) + if not dag_step_id and (current_task or request.display_state != "missing"): + language_request = request.language_text language_directives = output_language_directives( output_language, section=( "root_existing_request" - if not output_language and language_request == current_task + if request.display_state == "missing" else "root_system_context" ), request=language_request, ) parts.append( - "Current user request:\n" - f"{current_task}\n\n" - "Conversation focus rules: answer the current user request above. " + "Conversation focus rules: answer the latest independent user " + "request in the conversation. " "Earlier user and assistant messages are context only; use them to " "resolve references and preserve continuity, but do not re-answer " "previous requests or repeat previous final answers unless the " @@ -635,7 +619,11 @@ def _system_context(self) -> str: request_anchor = output_language_directives( output_language, section="dag_step_request_anchor", - request=self._current_user_request_text(prefer_display=True), + request=( + request.language_text + if request.display_state != "missing" + else None + ), ) if request_anchor: parts.append(request_anchor) @@ -1526,6 +1514,17 @@ def estimate_context_tokens(self) -> int: return self._get_total_tokens() def _get_total_tokens(self) -> int: + rendered_estimate = self._estimate_message_tokens(self.messages) + max( + 1, + len( + "\n\n".join( + part + for part in (self.system_prompt, self._system_context()) + if part + ) + ) + // 4, + ) if self.llm_calls: latest_call = self.llm_calls[-1] if latest_call.input_tokens > 0: @@ -1543,8 +1542,11 @@ def _get_total_tokens(self) -> int: delta_chars = self._message_content_chars( self.messages[prompt_message_count:] ) - return latest_call.input_tokens + max(0, delta_chars // 4) - return self._estimate_message_tokens(self.messages) + return max( + rendered_estimate, + latest_call.input_tokens + max(0, delta_chars // 4), + ) + return rendered_estimate def _estimate_message_tokens(self, messages: list[Message]) -> int: return sum( diff --git a/src/xagent/core/agent/language.py b/src/xagent/core/agent/language.py index 13894418ad..57cd89c0a6 100644 --- a/src/xagent/core/agent/language.py +++ b/src/xagent/core/agent/language.py @@ -507,7 +507,9 @@ def _soft_request_language_guidance( "Honor explicit and implicit requests to translate, rewrite, or answer in " "another language. Names, email addresses, connector metadata, quoted " "source content, memory, tool results, examples, and earlier turns are not " - "language evidence. " + "language evidence. An answer to a pending agent question does not replace " + "the independent request as language evidence unless that answer explicitly " + "asks to translate, rewrite, or continue the response in another language. " f"If {empty_subject} is empty, too short, mixed-language, or depends on " "conversation context, resolve its meaning from the conversation without " "guessing from auxiliary context. For Chinese, preserve Simplified Chinese " @@ -527,10 +529,10 @@ def _structured_request_language_policy(request_field: str) -> str: def _root_request_language_policy() -> str: - """Reference the root request already rendered immediately above.""" + """Reference the root request already present as a user message.""" return ( "Request-only response language policy: " - f"{_soft_request_language_guidance(subject='current user request above', empty_subject='request', boundary='policy')}" + f"{_soft_request_language_guidance(subject='latest independent user message in the conversation', empty_subject='request', boundary='policy')}" ) @@ -613,7 +615,7 @@ def output_language_directives( language: str | None, *, section: OutputLanguageSection, - request: str = "", + request: str | None = None, ) -> str: """Return the language instructions one prompt section must emit. @@ -625,7 +627,7 @@ def output_language_directives( # beside it would hand the model a second, competing rule. if language: return f"Output language policy:\n{output_language_policy(language)}" - return request_only_language_harness(request) + return request_only_language_harness(request or "") if section == "root_existing_request": if language: return f"Output language policy:\n{output_language_policy(language)}" @@ -637,8 +639,10 @@ def output_language_directives( if section == "dag_step_request_anchor": # A step context never carries the request itself, so quote it here -- but # only when no pinned language already answers the same question. - if language or not request: + if language: return "" + if request is None: + return _root_request_language_policy() # Quoted whole: any truncation can drop an explicit target-language # instruction sitting in the middle of a long request. return request_only_language_harness(request) @@ -650,4 +654,4 @@ def output_language_directives( return _structured_request_language_policy("latest_user_request") if section == "completion_assessment": return _structured_request_language_policy("user_authored_language_request") - return request_only_language_harness(request) + return request_only_language_harness(request or "") diff --git a/src/xagent/core/agent/pattern/dag/dag.py b/src/xagent/core/agent/pattern/dag/dag.py index 0309c1a821..8fe3f078ed 100644 --- a/src/xagent/core/agent/pattern/dag/dag.py +++ b/src/xagent/core/agent/pattern/dag/dag.py @@ -14,7 +14,9 @@ ) from ...context.enrichment import ( enrich_context_with_memory, + language_prompt_message, latest_user_text, + top_level_user_request, ) from ...frame import ExecutionFrame, ExecutionSnapshot, ExecutionStatus from ...grounding import grounding_rule @@ -1503,15 +1505,15 @@ async def _assess_completion( return assessment def _completion_assessment_messages(self, context: Any) -> list[dict[str, Any]]: - language_request = latest_user_text(context, prefer_display=True) or "" + language_request = top_level_user_request(context).language_text output_language = effective_output_language(context) latest_messages = [ - {"role": message.role, "content": message.content} + language_prompt_message(message) for message in getattr(context, "messages", []) if getattr(message, "role", None) in {"user", "assistant", "tool"} ] authoritative_user_requests = [ - {"role": message.role, "content": message.content} + language_prompt_message(message) for message in getattr(context, "messages", []) if getattr(message, "role", None) == "user" ] @@ -1893,11 +1895,30 @@ def _forward_user_response_to_waiting_step(self, root_context: Any) -> bool: child_context = type(root_context).from_dict(active_context) self._refresh_restored_step_runtime_metadata(child_context, root_context) + state = self.active_step_pattern_states.get(step_id) + waiting_request = ( + state.get("waiting_for_user_request") if isinstance(state, dict) else {} + ) + waiting_request = waiting_request if isinstance(waiting_request, dict) else {} for message in root_user_messages[self.planned_user_message_count :]: + waiting_marker = { + "question": str(waiting_request.get("message") or ""), + "message_type": waiting_request.get("message_type", "question"), + } + metadata = { + **getattr(message, "metadata", {}), + "response_to_waiting_for_user": waiting_marker, + } + root_index = next( + index + for index, root_message in enumerate(root_context.messages) + if root_message is message + ) + root_context.messages[root_index] = replace(message, metadata=metadata) child_context.add_user_message( message.content, metadata={ - **getattr(message, "metadata", {}), + **metadata, "kind": "dag_waiting_user_response", "forwarded_from_root": True, "dag_step_id": step_id, diff --git a/src/xagent/core/agent/pattern/dag/plan_generator.py b/src/xagent/core/agent/pattern/dag/plan_generator.py index 95bb85a92b..10be7e95f4 100644 --- a/src/xagent/core/agent/pattern/dag/plan_generator.py +++ b/src/xagent/core/agent/pattern/dag/plan_generator.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from typing import Any, Callable -from ...context.enrichment import latest_user_text +from ...context.enrichment import language_prompt_message, top_level_user_request from ...language import ( OUTPUT_LANGUAGE_SOURCE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_PLAN, @@ -561,10 +561,10 @@ def _plan_tool_schema(self) -> dict[str, Any]: } def _build_prompt(self, request: PlanGenerationRequest) -> str: - latest_request = latest_user_text(request.context, prefer_display=True) or "" + latest_request = top_level_user_request(request.context).language_text expected_language, language_source = self._language_authority(request.context) latest_messages = [ - {"role": message.role, "content": message.content} + language_prompt_message(message) for message in request.context.messages if getattr(message, "role", None) in {"user", "assistant", "tool"} ] @@ -688,7 +688,7 @@ def _request_language_reminder(context: Any, plan: ExecutionPlan) -> str | None: Script comparison cannot tell a biased plan from a request that legitimately asks for another language, so it may only nudge once, never reject a plan. """ - request = latest_user_text(context, prefer_display=True) or "" + request = top_level_user_request(context).language_text for step in plan.steps: mismatch = detect_prose_script_mismatch( request, LLMPlanGenerator._step_prose(step) diff --git a/tests/core/agent/test_auto.py b/tests/core/agent/test_auto.py index e57aefe9b1..3e886f60fa 100644 --- a/tests/core/agent/test_auto.py +++ b/tests/core/agent/test_auto.py @@ -2292,11 +2292,15 @@ async def test_stale_memory_language_does_not_reach_child_as_hard_policy() -> No assert result["success"] is True assert OUTPUT_LANGUAGE_METADATA_KEY not in context.metadata assert child.kwargs is not None - child_system = child.kwargs["context"].get_messages_for_llm()[0]["content"] + child_messages = child.kwargs["context"].get_messages_for_llm() + child_system = child_messages[0]["content"] assert "请始终使用中文回答。" in child_system assert "Output language:" not in child_system assert "Output language policy:" not in child_system - assert "Summarize the quarterly revenue trend in one paragraph." in child_system + assert "Summarize the quarterly revenue trend in one paragraph." not in child_system + assert child_messages[-1]["content"] == ( + "Summarize the quarterly revenue trend in one paragraph." + ) assert ( output_language_directives("", section="root_existing_request") in child_system ) @@ -2331,8 +2335,10 @@ async def test_direct_final_answer_allows_an_explicit_target_language() -> None: assert ( target_rule in tool_schema["parameters"]["properties"]["answer"]["description"] ) - system_content = context.get_messages_for_llm()[0]["content"] - assert system_content.count(request) == 1 + messages = context.get_messages_for_llm() + system_content = messages[0]["content"] + assert system_content.count(request) == 0 + assert sum(message["content"].count(request) for message in messages) == 1 assert ( output_language_directives("", section="root_existing_request") in system_content diff --git a/tests/core/agent/test_context.py b/tests/core/agent/test_context.py index c8dfc8a722..e49ef78cd8 100644 --- a/tests/core/agent/test_context.py +++ b/tests/core/agent/test_context.py @@ -185,11 +185,13 @@ def test_system_context_preserves_current_request_language_over_memory() -> None system_message = ctx.get_messages_for_llm()[0]["content"] - assert "Current user request:" in system_message - assert "Can you analyze this GitHub project?" in system_message + assert "Current user request:" not in system_message + assert ctx.get_messages_for_llm()[-1]["content"] == ( + "Can you analyze this GitHub project?" + ) assert "Response language rules" in system_message assert ( - "Use the same natural language as the current user request above" + "Use the same natural language as the latest independent user message" in system_message ) assert "Do not let retrieved memories" in system_message @@ -322,11 +324,13 @@ def test_system_context_uses_latest_user_message_as_current_request() -> None: ctx.add_assistant_message("Sure, here is the analysis.") ctx.add_user_message("请继续用中文总结") - system_message = ctx.get_messages_for_llm()[0]["content"] + messages = ctx.get_messages_for_llm() + system_message = messages[0]["content"] - assert "Current user request:\n请继续用中文总结" in system_message - assert "Current user request:\nCan you analyze this GitHub project?" not in ( - system_message + assert "latest independent user message" in system_message + assert system_message.count("请继续用中文总结") == 0 + assert ( + sum(message["content"].count("请继续用中文总结") for message in messages) == 1 ) @@ -348,8 +352,10 @@ def test_system_context_ignores_waiting_for_user_answer_as_current_request() -> system_message = messages[0]["content"] waiting_answer_message = messages[-1]["content"] - assert "Current user request:\nBook a trip" in system_message - assert "Current user request:\n北京" not in system_message + assert "latest independent user message" in system_message + assert "Book a trip" not in system_message + assert "北京" not in system_message + assert messages[1]["content"] == "Book a trip" assert "answer to a pending agent question" in waiting_answer_message assert "User answer: 北京" in waiting_answer_message @@ -676,8 +682,8 @@ def test_get_messages_for_llm_injects_current_request_focus() -> None: result = ctx.get_messages_for_llm() system_content = result[0]["content"] - assert "Current user request:" in system_content - assert "Compare Mistral, OpenAI, and Anthropic ARR." in system_content + assert "Current user request:" not in system_content + assert result[-1]["content"] == "Compare Mistral, OpenAI, and Anthropic ARR." assert "Earlier user and assistant messages are context only" in system_content assert "do not re-answer previous requests" in system_content @@ -704,17 +710,19 @@ def test_get_messages_for_llm_uses_compact_dag_output_language_policy() -> None: assert [message["role"] for message in result].count("system") == 1 -def test_dag_step_without_output_language_quotes_the_request_for_language() -> None: +def test_dag_step_without_output_language_references_the_existing_request() -> None: ctx = ExecutionContext() ctx.metadata["task"] = "Crée deux affiches." ctx.metadata["dag_step_id"] = "step-1" ctx.metadata["dag_step_name"] = "Extract release notes" ctx.add_user_message("Crée deux affiches.") - system_content = ctx.get_messages_for_llm()[0]["content"] + messages = ctx.get_messages_for_llm() + system_content = messages[0]["content"] - assert "Request-only response language harness:" in system_content - assert "Crée deux affiches." in system_content + assert "latest independent user message" in system_content + assert "Crée deux affiches." not in system_content + assert messages[-1]["content"] == "Crée deux affiches." assert "Response language rules:" in system_content assert "Output language:" not in system_content @@ -726,9 +734,11 @@ def test_dag_step_language_quote_keeps_a_mid_request_directive() -> None: ctx.metadata["dag_step_name"] = "Extract release notes" ctx.add_user_message(request) - system_content = ctx.get_messages_for_llm()[0]["content"] + messages = ctx.get_messages_for_llm() + system_content = messages[0]["content"] - assert request in system_content + assert request not in system_content + assert messages[-1]["content"] == request assert "middle truncated" not in system_content @@ -1560,11 +1570,11 @@ def test_compact_disabled() -> None: def test_token_estimate_uses_latest_prompt_usage_plus_append_delta() -> None: ctx = ExecutionContext() ctx.add_user_message("a" * 20) - ctx.record_llm_usage(input_tokens=100, output_tokens=10) + ctx.record_llm_usage(input_tokens=10_000, output_tokens=10) ctx.add_assistant_message("b" * 16) ctx.add_user_message("c" * 8) - assert ctx._get_total_tokens() == 106 + assert ctx._get_total_tokens() == 10_006 def test_token_estimate_falls_back_when_history_is_rewritten() -> None: @@ -1573,7 +1583,11 @@ def test_token_estimate_falls_back_when_history_is_rewritten() -> None: ctx.record_llm_usage(input_tokens=100, output_tokens=10) ctx.messages[0] = Message.role_user("rewritten") - assert ctx._get_total_tokens() == max(1, len("rewritten") // 4) + expected = ctx._estimate_message_tokens(ctx.messages) + max( + 1, + len("\n\n".join((ctx._system_context(),))) // 4, + ) + assert ctx._get_total_tokens() == expected def test_serialization_roundtrip() -> None: diff --git a/tests/core/agent/test_dag.py b/tests/core/agent/test_dag.py index b01a42b79a..c4a7b630ef 100644 --- a/tests/core/agent/test_dag.py +++ b/tests/core/agent/test_dag.py @@ -30,6 +30,7 @@ OUTPUT_LANGUAGE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_METADATA_KEY, OUTPUT_LANGUAGE_SOURCE_PLAN, + output_language_directives, request_only_language_harness, ) from xagent.core.agent.pattern.base import RequiredToolCallError @@ -454,6 +455,14 @@ def test_dag_waiting_response_preserves_active_step_state() -> None: "kind": "dag_waiting_user_response", "forwarded_from_root": True, "dag_step_id": "confirm", + "response_to_waiting_for_user": { + "question": "Choose A or B", + "message_type": "question", + }, + } + assert root_context.messages[-1].metadata["response_to_waiting_for_user"] == { + "question": "Choose A or B", + "message_type": "question", } @@ -1071,10 +1080,10 @@ async def test_dag_step_appends_current_step_boundary_after_parent_context() -> assert "Overall user goal is background context only" in messages[0]["content"] assert "Output language policy" in messages[0]["content"] assert ( - request_only_language_harness("Extract highlights and generate two posters.") + output_language_directives("", section="root_existing_request") in messages[0]["content"] ) - assert "Extract highlights and generate two posters." in messages[0]["content"] + assert "Extract highlights and generate two posters." not in messages[0]["content"] assert "Extract highlights and generate two posters." not in messages[-1]["content"] assert "Current step id: extract" in messages[0]["content"] assert "Detailed step boundary rules" in messages[0]["content"] @@ -5191,11 +5200,13 @@ async def run(self, **kwargs: Any) -> dict[str, Any]: assert OUTPUT_LANGUAGE_METADATA_KEY not in context.metadata assert sorted(captured) == ["compare", "write"] for child in captured.values(): - system_content = child.get_messages_for_llm()[0]["content"] + messages = child.get_messages_for_llm() + system_content = messages[0]["content"] assert "Output language: Simplified Chinese" not in system_content assert "Output language:" not in system_content - assert request in system_content - assert request_only_language_harness(request) in system_content + assert request not in system_content + assert sum(message["content"].count(request) for message in messages) == 1 + assert "latest independent user message" in system_content step_instruction = [ message.content for message in child.messages diff --git a/tests/core/agent/test_output_language_seam.py b/tests/core/agent/test_output_language_seam.py index 2a83faadb2..8a24881953 100644 --- a/tests/core/agent/test_output_language_seam.py +++ b/tests/core/agent/test_output_language_seam.py @@ -93,8 +93,9 @@ def test_output_language_directives_render_each_section_verbatim() -> None: assert output_language_directives( "", section="root_system_context" ) == request_only_language_harness("") - assert "current user request above" in output_language_directives( - "", section="root_existing_request" + assert ( + "latest independent user message in the conversation" + in output_language_directives("", section="root_existing_request") ) assert ( output_language_directives("Japanese", section="dag_step_scope") @@ -104,6 +105,12 @@ def test_output_language_directives_render_each_section_verbatim() -> None: output_language_directives("", section="dag_step_scope") == output_language_policy("").strip() ) + assert "latest independent user message" in output_language_directives( + "", section="dag_step_request_anchor", request=None + ) + assert request_only_language_harness("") == output_language_directives( + "", section="dag_step_request_anchor", request="" + ) assert ( output_language_directives("Japanese", section="dag_step_rules") == dag_step_language_rules() diff --git a/tests/core/agent/test_request_language_harness.py b/tests/core/agent/test_request_language_harness.py index 476680f1d9..f3f99d4e8f 100644 --- a/tests/core/agent/test_request_language_harness.py +++ b/tests/core/agent/test_request_language_harness.py @@ -6,7 +6,10 @@ import pytest from xagent.core.agent.context import ExecutionContext -from xagent.core.agent.context.enrichment import latest_user_text +from xagent.core.agent.context.enrichment import ( + latest_user_text, + top_level_user_request, +) from xagent.core.agent.language import ( OUTPUT_LANGUAGE_METADATA_KEY, final_answer_language_rule, @@ -133,6 +136,13 @@ def test_blank_display_message_is_an_authoritative_empty_language_request( assert "Gerard Santos" not in completion_payload["output_language_policy"] +def test_file_only_blank_display_keeps_an_empty_root_language_anchor() -> None: + context = ExecutionContext(execution_id="file-only-blank-language") + context.add_user_message("", metadata={"display_message": " \n"}) + + assert request_only_language_harness("") in context._system_context() + + @pytest.mark.parametrize( "metadata", [ @@ -244,11 +254,16 @@ def test_root_language_request_appears_once_for_every_display_shape( ) system_context = context._system_context() + provider_prompt = context.get_messages_for_llm() + provider_text = "\n".join(message["content"] for message in provider_prompt) rendered_policy = expected_policy.format( quoted_request=request_only_language_harness(request) ) - assert system_context.count(request) == 1 + assert system_context.count(request) == (1 if quotes_request else 0) + assert provider_text.count(request) == system_context.count( + request + ) + content.format(request=request).count(request) assert rendered_policy in system_context if quotes_request: assert request_only_language_harness(request) in system_context @@ -256,6 +271,122 @@ def test_root_language_request_appears_once_for_every_display_shape( assert request_only_language_harness(request) not in system_context +@pytest.mark.parametrize("display_message", ["", " \n\t"]) +@pytest.mark.parametrize("restored", [False, True], ids=["fresh", "restored"]) +def test_dag_step_preserves_authoritative_blank_display_anchor( + display_message: str, + restored: bool, +) -> None: + root = ExecutionContext(execution_id="dag-blank-root") + root.add_user_message( + POLLUTED_EXECUTION_REQUEST, + metadata={"display_message": display_message}, + ) + child = root.create_child_context( + execution_id="dag-blank-root:step", + metadata={"dag_step_id": "draft", "dag_step_name": "Draft reply"}, + ) + if restored: + child = ExecutionContext.from_dict(child.to_dict()) + + system_context = child._system_context() + + assert request_only_language_harness("") in system_context + assert ( + request_only_language_harness(POLLUTED_EXECUTION_REQUEST) not in system_context + ) + + +def _waiting_dag_context(answer: str) -> tuple[DAGPattern, ExecutionContext]: + root = ExecutionContext(execution_id="dag-language-wait") + root.add_user_message( + POLLUTED_EXECUTION_REQUEST, + metadata={"display_message": ENGLISH_REQUEST}, + ) + child = root.create_child_context(execution_id="dag-language-wait:confirm") + pattern = DAGPattern(lambda **_: None) + pattern.status = "waiting_for_user" + pattern.active_step_id = "confirm" + pattern.active_step_ids = ["confirm"] + pattern.active_step_contexts = {"confirm": child.to_dict()} + pattern.active_step_pattern_states = { + "confirm": { + "status": "waiting_for_user", + "waiting_for_user_request": {"message": "Which date?"}, + } + } + pattern.planned_user_message_count = 1 + root.add_user_message(answer) + assert pattern._forward_user_response_to_waiting_step(root) + return pattern, root + + +@pytest.mark.parametrize("cold_restore", [False, True], ids=["live", "cold-restored"]) +@pytest.mark.parametrize( + "answer", + [ + pytest.param("Solo el viernes.", id="cross-language-answer"), + pytest.param( + "Continúa la respuesta en español.", + id="explicit-language-switch", + ), + ], +) +def test_dag_wait_response_keeps_top_level_language_boundary( + answer: str, + cold_restore: bool, +) -> None: + pattern, root = _waiting_dag_context(answer) + if cold_restore: + restored_pattern = DAGPattern(lambda **_: None) + restored_pattern.load_state(pattern.get_state()) + pattern = restored_pattern + root = ExecutionContext.from_dict(root.to_dict()) + + request = top_level_user_request(root) + plan_payload = json.loads( + LLMPlanGenerator()._build_prompt( + PlanGenerationRequest( + context=root, + execution_id=root.execution_id, + available_tool_names=[], + ) + ) + ) + completion = json.loads(pattern._completion_assessment_messages(root)[1]["content"]) + + assert request.language_text == ENGLISH_REQUEST + assert request.has_pending_response is True + assert plan_payload["latest_user_request"] == ENGLISH_REQUEST + plan_answer = next( + item for item in plan_payload["messages"] if item.get("content") == answer + ) + assert plan_answer["user_message_context"] == "pending_agent_question_response" + assert completion["user_authored_language_request"] == ENGLISH_REQUEST + answer_payload = next( + item for item in completion["messages"] if item.get("content") == answer + ) + assert answer_payload["user_message_context"] == "pending_agent_question_response" + assert "unless that answer explicitly asks" in completion["output_language_policy"] + + +def test_large_split_request_has_canonical_provider_copies_and_budget() -> None: + display = "DISPLAY_SENTINEL_" + "E" * 20_000 + execution = display + "\nCONNECTOR_SENTINEL_" + "F" * 20_000 + context = ExecutionContext(execution_id="canonical-language-budget") + context.add_user_message(execution, metadata={"display_message": display}) + + provider_messages = context.get_messages_for_llm() + provider_text = "\n".join(message["content"] for message in provider_messages) + + assert provider_text.count(execution) == 1 + assert provider_text.count(request_only_language_harness(display)) == 1 + assert provider_text.count(display) == 2 + assert context.estimate_context_tokens() >= sum( + max(1, len(message["content"]) // 4) for message in provider_messages + ) + + def test_caller_pinned_language_remains_the_only_hard_authority() -> None: context = _polluted_context() context.metadata["request_context"] = {OUTPUT_LANGUAGE_METADATA_KEY: "French"} From f17439c6c5ad2323338fc517d03aa5e272bec46b Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Wed, 2 Sep 2026 11:26:47 +0800 Subject: [PATCH 07/10] test(agent): align restored prompt assertions --- tests/core/agent/test_runner.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/core/agent/test_runner.py b/tests/core/agent/test_runner.py index a28eeeda72..4b59654a07 100644 --- a/tests/core/agent/test_runner.py +++ b/tests/core/agent/test_runner.py @@ -1907,6 +1907,19 @@ async def test_inject_user_message_raises_corrupt_on_contextless_checkpoint() -> ) +def _assert_unpinned_restored_request_prompt(context: ExecutionContext) -> None: + request = "Summarize the release notes in one paragraph." + provider_messages = context.get_messages_for_llm() + system_content = provider_messages[0]["content"] + assert "Output language: Simplified Chinese" not in system_content + assert request not in system_content + assert "latest independent user message" in system_content + assert sum(message["content"].count(request) for message in provider_messages) == 1 + assert any( + message == {"role": "user", "content": request} for message in provider_messages + ) + + @pytest.mark.asyncio async def test_resume_drops_legacy_router_output_language(tmp_path: Path) -> None: checkpoint_context = ExecutionContext(execution_id="exec-legacy-router-language") @@ -1944,9 +1957,7 @@ async def test_resume_drops_legacy_router_output_language(tmp_path: Path) -> Non restored_child = pattern.state["active_step_contexts"]["step_1"]["metadata"] assert OUTPUT_LANGUAGE_METADATA_KEY not in restored_child assert OUTPUT_LANGUAGE_SOURCE_METADATA_KEY not in restored_child - system_content = result["context"].get_messages_for_llm()[0]["content"] - assert "Output language: Simplified Chinese" not in system_content - assert "Summarize the release notes in one paragraph." in system_content + _assert_unpinned_restored_request_prompt(result["context"]) @pytest.mark.asyncio @@ -1986,9 +1997,7 @@ async def test_resume_drops_legacy_plan_output_language(tmp_path: Path) -> None: restored_child = pattern.state["active_step_contexts"]["step_1"]["metadata"] assert OUTPUT_LANGUAGE_METADATA_KEY not in restored_child assert OUTPUT_LANGUAGE_SOURCE_METADATA_KEY not in restored_child - system_content = result["context"].get_messages_for_llm()[0]["content"] - assert "Output language: Simplified Chinese" not in system_content - assert "Summarize the release notes in one paragraph." in system_content + _assert_unpinned_restored_request_prompt(result["context"]) @pytest.mark.asyncio From 0032c18e98abadeb922d0c8b7239152d114602aa Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Wed, 2 Sep 2026 17:11:05 +0800 Subject: [PATCH 08/10] fix(agent): preserve language intent through compaction --- src/xagent/core/agent/context/enrichment.py | 56 ++++++- src/xagent/core/agent/context/execution.py | 6 + src/xagent/core/agent/language.py | 93 ++++++----- .../core/agent/pattern/dag/plan_generator.py | 17 +- .../core/tools/adapters/vibe/agent_tool.py | 13 +- tests/core/agent/test_auto.py | 2 +- tests/core/agent/test_context.py | 18 +- tests/core/agent/test_output_language_seam.py | 8 +- .../agent/test_request_language_harness.py | 157 +++++++++++++++++- tests/core/tools/test_create_agent_tool.py | 19 ++- 10 files changed, 307 insertions(+), 82 deletions(-) diff --git a/src/xagent/core/agent/context/enrichment.py b/src/xagent/core/agent/context/enrichment.py index f2c3b26ffd..4783d27547 100644 --- a/src/xagent/core/agent/context/enrichment.py +++ b/src/xagent/core/agent/context/enrichment.py @@ -23,6 +23,7 @@ DisplayMessageState = Literal["missing", "empty", "text"] +TOP_LEVEL_USER_REQUEST_METADATA_KEY = "_xagent_top_level_user_request" @dataclass(frozen=True) @@ -35,6 +36,40 @@ class TopLevelUserRequest: has_pending_response: bool = False +def _stored_top_level_user_request(context: Any) -> TopLevelUserRequest | None: + metadata = getattr(context, "metadata", None) + if not isinstance(metadata, dict): + return None + payload = metadata.get(TOP_LEVEL_USER_REQUEST_METADATA_KEY) + if not isinstance(payload, dict): + return None + execution_text = payload.get("execution_text") + language_text = payload.get("language_text") + display_state = payload.get("display_state") + if ( + not isinstance(execution_text, str) + or not isinstance(language_text, str) + or display_state not in {"missing", "empty", "text"} + ): + return None + return TopLevelUserRequest( + execution_text=execution_text, + language_text=language_text, + display_state=display_state, + ) + + +def _persist_top_level_user_request(context: Any, request: TopLevelUserRequest) -> None: + metadata = getattr(context, "metadata", None) + if not isinstance(metadata, dict): + return + metadata[TOP_LEVEL_USER_REQUEST_METADATA_KEY] = { + "execution_text": request.execution_text, + "language_text": request.language_text, + "display_state": request.display_state, + } + + async def enrich_context_with_memory( *, context: Any, @@ -168,18 +203,31 @@ def top_level_user_request(context: Any) -> TopLevelUserRequest: if display_text is None: if not execution_text: continue - return TopLevelUserRequest( + request = TopLevelUserRequest( execution_text=execution_text, language_text=execution_text, display_state="missing", has_pending_response=has_pending_response, ) - return TopLevelUserRequest( + _persist_top_level_user_request(context, request) + return request + request = TopLevelUserRequest( execution_text=execution_text, language_text=display_text, display_state="text" if display_text else "empty", has_pending_response=has_pending_response, ) + _persist_top_level_user_request(context, request) + return request + + stored = _stored_top_level_user_request(context) + if stored is not None: + return TopLevelUserRequest( + execution_text=stored.execution_text, + language_text=stored.language_text, + display_state=stored.display_state, + has_pending_response=has_pending_response, + ) task = ( context.metadata.get("task") @@ -187,12 +235,14 @@ def top_level_user_request(context: Any) -> TopLevelUserRequest: else None ) task_text = str(task or "").strip() - return TopLevelUserRequest( + request = TopLevelUserRequest( execution_text=task_text, language_text=task_text, display_state="missing", has_pending_response=has_pending_response, ) + _persist_top_level_user_request(context, request) + return request def language_prompt_message(message: Any) -> dict[str, Any]: diff --git a/src/xagent/core/agent/context/execution.py b/src/xagent/core/agent/context/execution.py index 82a9ce3cb7..f0cb596b78 100644 --- a/src/xagent/core/agent/context/execution.py +++ b/src/xagent/core/agent/context/execution.py @@ -907,6 +907,9 @@ def create_child_context( include_system_prompt: bool = True, metadata: dict[str, Any] | None = None, ) -> "ExecutionContext": + # Child compaction may discard the copied root message, so snapshot its + # clean request provenance before metadata is cloned. + top_level_user_request(self) child_metadata = dict(self.metadata) if metadata: child_metadata.update(metadata) @@ -1076,6 +1079,7 @@ def compact_if_needed(self, llm: Any = None) -> CompactResult: strategy="none", ) + top_level_user_request(self) total_tokens = self._get_total_tokens() if total_tokens > self.compact_config.threshold: result = self._compact(llm) @@ -1092,6 +1096,7 @@ def build_llm_compact_request_if_needed(self) -> dict[str, Any] | None: if not self.compact_config.enabled: return None + top_level_user_request(self) total_tokens = self._get_total_tokens() if total_tokens <= self.compact_config.threshold: return None @@ -1168,6 +1173,7 @@ def compact_with_llm_response( llm: Any = None, original_tokens: int | None = None, ) -> CompactResult: + top_level_user_request(self) original_count = len(self.messages) summary = ( "" diff --git a/src/xagent/core/agent/language.py b/src/xagent/core/agent/language.py index 57cd89c0a6..94f5d3687c 100644 --- a/src/xagent/core/agent/language.py +++ b/src/xagent/core/agent/language.py @@ -393,6 +393,31 @@ def reset_metadata_output_language(metadata: dict[str, Any]) -> None: metadata[OUTPUT_LANGUAGE_METADATA_KEY] = external +def _canonical_unpinned_language_guidance( + *, subject: str, empty_subject: str, boundary: str +) -> str: + """Render the sole soft language policy for an unpinned request.""" + return ( + f"Use the {subject} as the baseline language authority for all user-facing " + "prose and tool arguments that persist user-facing prose. Honor explicit or " + f"implicit target-language intent in the {subject}, including requests to " + "translate, rewrite, or answer for an audience that requires another " + "language. Only a user message explicitly marked as the answer to a pending " + "agent question may override that baseline, and only when the marked answer " + "explicitly asks to translate, rewrite, or continue the response in another " + "language. Other answers and earlier turns remain conversation context, not " + "language authority. Names, email addresses, connector metadata, quoted " + "source content, memory, tool results, examples, DAG text, and dependency " + "results are not language evidence. " + f"If {empty_subject} is empty, too short, mixed-language, or depends on " + "conversation context, resolve its meaning from the conversation without " + "guessing from auxiliary context. For Chinese, preserve Simplified Chinese " + f"versus Traditional Chinese from the {subject}; do not collapse them into " + f"generic Chinese. This {boundary} controls " + "language only; it does not replace or narrow the executable request." + ) + + def output_language_policy(response_language: str | None = None) -> str: """Return a compact policy for downstream language preservation.""" language = normalize_response_language_label(response_language) @@ -406,18 +431,14 @@ def output_language_policy(response_language: str | None = None) -> str: "request when generic Chinese is specified: Simplified Chinese and " "Traditional Chinese are different output languages. Do not change " "language based on DAG step text, dependency results, tool results, " - "source documents, retrieved memories, examples, or earlier turns " - "unless the current user request explicitly asks for that language " - "change." + "source documents, retrieved memories, examples, the current request, " + "or earlier turns. A caller-pinned output language is the sole hard " + "authority." ) - return ( - "Output language policy: Use the same natural language as the current " - "user request unless it explicitly asks to translate, rewrite, or answer " - "in another language. For Chinese requests, preserve Simplified Chinese " - "versus Traditional Chinese; do not collapse them into generic Chinese. " - "Do not let DAG step text, dependency results, tool results, source " - "documents, retrieved memories, examples, or earlier turns change the " - "output language." + return "Output language policy: " + _canonical_unpinned_language_guidance( + subject="current independent user request", + empty_subject="that request", + boundary="policy", ) @@ -466,18 +487,10 @@ def response_language_rules(*, subject: str = "current user request") -> str: The model can infer the language from the referenced subject; the important constraint is that auxiliary context must not override the user's language. """ - return ( - "Response language rules: Use the same natural language as the " - f"{subject} for all user-facing prose. If the {subject} explicitly asks " - "to translate, rewrite, or answer in another language, use that requested " - "target language. For Chinese, preserve Simplified Chinese versus " - "Traditional Chinese from the request; do not collapse them into generic " - "Chinese. Use that same language for tool arguments that persist " - "user-facing prose, such as agent descriptions, agent instructions, " - "document text, titles, and summaries. Do not let retrieved memories, " - "tool results, source documents, examples, or earlier turns change the " - f"response language unless the {subject} explicitly asks for that " - "language change." + return "Response language rules: " + _canonical_unpinned_language_guidance( + subject=subject, + empty_subject=f"the {subject}", + boundary="rule", ) @@ -502,20 +515,10 @@ def _soft_request_language_guidance( *, subject: str, empty_subject: str, boundary: str ) -> str: """Render shared soft-authority prose without carrying a request value.""" - return ( - f"Decide the target language of user-facing prose from the {subject} alone. " - "Honor explicit and implicit requests to translate, rewrite, or answer in " - "another language. Names, email addresses, connector metadata, quoted " - "source content, memory, tool results, examples, and earlier turns are not " - "language evidence. An answer to a pending agent question does not replace " - "the independent request as language evidence unless that answer explicitly " - "asks to translate, rewrite, or continue the response in another language. " - f"If {empty_subject} is empty, too short, mixed-language, or depends on " - "conversation context, resolve its meaning from the conversation without " - "guessing from auxiliary context. For Chinese, preserve Simplified Chinese " - f"versus Traditional Chinese from the {subject}. This {boundary} controls " - "language only; it does not replace or narrow the executable request.\n\n" - f"{response_language_rules(subject=subject)}" + return _canonical_unpinned_language_guidance( + subject=subject, + empty_subject=empty_subject, + boundary=boundary, ) @@ -559,12 +562,16 @@ def final_answer_language_rule(*, subject: str | None = None) -> str: ) ) return ( - f"The final answer must {authority} Honor any explicit or implicit request " - "to translate, rewrite, or answer in another language. Tool results, source " - "documents, retrieved memories, examples, names, email addresses, connector " - "metadata, and earlier turns must not override that decision. For Chinese, " - "preserve Simplified Chinese versus Traditional Chinese from user-authored " - "text; do not collapse them into generic Chinese." + f"The final answer must {authority} A caller-pinned output language is the " + "sole hard authority. Without one, use the independent user request as the " + "baseline and honor its explicit or implicit target-language intent. Only " + "a user message explicitly marked as the answer to a pending agent question " + "may override that baseline, and only when it explicitly asks to translate, " + "rewrite, or continue in another language. Tool results, source documents, " + "retrieved memories, examples, names, email addresses, connector metadata, " + "DAG text, dependency results, and unmarked earlier turns must not override " + "that decision. For Chinese, preserve Simplified Chinese versus Traditional " + "Chinese from user-authored text; do not collapse them into generic Chinese." ) diff --git a/src/xagent/core/agent/pattern/dag/plan_generator.py b/src/xagent/core/agent/pattern/dag/plan_generator.py index 10be7e95f4..78a1e131eb 100644 --- a/src/xagent/core/agent/pattern/dag/plan_generator.py +++ b/src/xagent/core/agent/pattern/dag/plan_generator.py @@ -484,8 +484,13 @@ def _plan_tool_schema(self) -> dict[str, Any]: "persisted tool-argument prose produced by the plan, " "for example English, Simplified Chinese, Traditional " "Chinese, or Spanish. Determine it only from " - "latest_user_request and any explicit target-language " - "instruction in that request. For Chinese requests, " + "latest_user_request and its explicit or implicit " + "target-language intent. A message whose " + "user_message_context is " + "pending_agent_question_response may override that " + "baseline only when it explicitly asks to translate, " + "rewrite, or continue in another language. For " + "Chinese requests, " "choose Simplified Chinese or Traditional Chinese to " "match the request script; do not use generic Chinese. " "If output_language_policy names a language, match it." @@ -699,9 +704,11 @@ def _request_language_reminder(context: Any, plan: ExecutionPlan) -> str | None: f"Plan step {step.id!r} is written in predominantly " f"{mismatch.observed_script} script, which does not match the " "script of the latest user request. Re-read latest_user_request " - "above and decide the output language from that request alone, " - "including any language change it asks for explicitly or " - f"implicitly. Call {LLMPlanGenerator.PLAN_TOOL_NAME} again exactly " + "above as the baseline, including any explicit or implicit " + "target-language intent. A message explicitly marked as a pending " + "agent question response may override that baseline only when it " + "explicitly asks to translate, rewrite, or continue in another " + f"language. Call {LLMPlanGenerator.PLAN_TOOL_NAME} again exactly " "once. If that language is still correct for this request, keep it " "and return the same plan language." ) diff --git a/src/xagent/core/tools/adapters/vibe/agent_tool.py b/src/xagent/core/tools/adapters/vibe/agent_tool.py index fa3f245499..e8e858fcc0 100644 --- a/src/xagent/core/tools/adapters/vibe/agent_tool.py +++ b/src/xagent/core/tools/adapters/vibe/agent_tool.py @@ -388,15 +388,16 @@ class CreateAgentToolArgs(BaseModel): "IMPORTANT: Description of when to use this agent (e.g., 'Use this " "agent for data analysis tasks involving CSV files'). This helps users " "understand the agent's purpose and when to call it. Write this in the " - "same natural language as the current output language policy unless the user " - "explicitly asks for another language." + "language required by the current output language policy; that policy " + "already accounts for valid user-requested language changes." ) ) instructions: str = Field( description=( "System instructions/prompt for the agent. Write persisted " - "user-facing prose in the same natural language as the current output " - "language policy unless the user explicitly asks for another language." + "user-facing prose in the language required by the current output " + "language policy; that policy already accounts for valid " + "user-requested language changes." ) ) tool_categories: Optional[list[str]] = Field( @@ -750,7 +751,7 @@ def description(self) -> str: "The agent will be created in DRAFT status and can be called immediately using the returned tool name.\n\n" "Parameters:\n" "- name: A short, descriptive name for the agent (e.g., 'researcher', 'data_analyzer')\n" - "- description: IMPORTANT - Clear description of when to use this agent (e.g., 'Use this agent for data analysis tasks involving CSV files'). This helps users understand the agent's purpose. Write this in the same natural language as the current output language policy unless the user explicitly asks for another language.\n" + "- description: IMPORTANT - Clear description of when to use this agent (e.g., 'Use this agent for data analysis tasks involving CSV files'). This helps users understand the agent's purpose. Write this in the language required by the current output language policy; that policy already accounts for valid user-requested language changes.\n" f"- tool_categories (optional): Available categories: {categories_list}\n" f" Example: ['file', 'knowledge', 'basic']\n" f"- knowledge_bases (optional): List of knowledge base names or IDs to link to this agent.\n" @@ -759,7 +760,7 @@ def description(self) -> str: "file upload, or existing knowledge base choice before calling this tool.\n" f"- skills (optional): Available skills: {skills_list}\n" f" Example: ['presentation-generator', 'evidence-based-rag']\n" - "- instructions: System prompt/instructions defining the agent's behavior and expertise. Write persisted user-facing prose in the same natural language as the current output language policy unless the user explicitly asks for another language. Do not inherit another language from DAG step text, dependency results, tool results, source documents, retrieved memories, examples, or earlier turns.\n" + "- instructions: System prompt/instructions defining the agent's behavior and expertise. Write persisted user-facing prose in the language required by the current output language policy; that policy already accounts for valid user-requested language changes. Do not inherit another language from DAG step text, dependency results, tool results, source documents, retrieved memories, examples, or earlier turns.\n" "- execution_mode (optional): 'flash', 'balanced' (default), 'think', or 'auto'\n\n" "Returns:\n" "- agent_id: Database ID of the created agent\n" diff --git a/tests/core/agent/test_auto.py b/tests/core/agent/test_auto.py index 3e886f60fa..69d3fbd18f 100644 --- a/tests/core/agent/test_auto.py +++ b/tests/core/agent/test_auto.py @@ -2329,7 +2329,7 @@ async def test_direct_final_answer_allows_an_explicit_target_language() -> None: assert result["success"] is True assert result["output"] == "La capitale de l'Italie est Rome." assert OUTPUT_LANGUAGE_METADATA_KEY not in context.metadata - target_rule = "Honor any explicit or implicit request to translate" + target_rule = "honor its explicit or implicit target-language intent" tool_schema = llm.calls[0]["tools"][0]["function"] assert target_rule in tool_schema["description"] assert ( diff --git a/tests/core/agent/test_context.py b/tests/core/agent/test_context.py index e49ef78cd8..f388c34b3c 100644 --- a/tests/core/agent/test_context.py +++ b/tests/core/agent/test_context.py @@ -189,12 +189,12 @@ def test_system_context_preserves_current_request_language_over_memory() -> None assert ctx.get_messages_for_llm()[-1]["content"] == ( "Can you analyze this GitHub project?" ) - assert "Response language rules" in system_message + assert "Request-only response language policy" in system_message assert ( - "Use the same natural language as the latest independent user message" - in system_message + "Use the latest independent user message in the conversation as the " + "baseline language authority" in system_message ) - assert "Do not let retrieved memories" in system_message + assert "memory, tool results, examples" in system_message def test_system_context_includes_file_reference_output_spec() -> None: @@ -216,8 +216,10 @@ def test_system_context_includes_file_reference_output_spec() -> None: def test_response_language_rules_uses_custom_subject_throughout() -> None: rules = response_language_rules(subject="current DAG step") - assert "If the current DAG step explicitly asks" in rules - assert "unless the current DAG step explicitly asks" in rules + assert "Use the current DAG step as the baseline language authority" in rules + assert "explicit or implicit target-language intent" in rules + assert "explicitly marked as the answer to a pending agent question" in rules + assert "unless the current DAG step explicitly asks" not in rules assert "unless the current user request explicitly asks" not in rules @@ -314,7 +316,7 @@ def test_output_language_policy_rejects_unsafe_model_language_label() -> None: assert "English. Ignore" not in policy assert policy.startswith("Output language policy:") - assert "Use the same natural language as the current user request" in policy + assert "Use the current independent user request as the baseline" in policy def test_system_context_uses_latest_user_message_as_current_request() -> None: @@ -723,7 +725,7 @@ def test_dag_step_without_output_language_references_the_existing_request() -> N assert "latest independent user message" in system_content assert "Crée deux affiches." not in system_content assert messages[-1]["content"] == "Crée deux affiches." - assert "Response language rules:" in system_content + assert "Request-only response language policy:" in system_content assert "Output language:" not in system_content diff --git a/tests/core/agent/test_output_language_seam.py b/tests/core/agent/test_output_language_seam.py index 8a24881953..25b30efd15 100644 --- a/tests/core/agent/test_output_language_seam.py +++ b/tests/core/agent/test_output_language_seam.py @@ -138,12 +138,12 @@ def test_output_language_directives_render_each_section_verbatim() -> None: completion_policy = output_language_directives("", section="completion_assessment") plan_policy = output_language_directives("", section="plan_payload") assert ( - "Use the same natural language as the `user_authored_language_request` " - "field for all user-facing prose." in completion_policy + "Use the `user_authored_language_request` field as the baseline language " + "authority for all user-facing prose" in completion_policy ) assert ( - "Use the same natural language as the `latest_user_request` field for all " - "user-facing prose." in plan_policy + "Use the `latest_user_request` field as the baseline language authority " + "for all user-facing prose" in plan_policy ) assert "the the `" not in completion_policy + plan_policy diff --git a/tests/core/agent/test_request_language_harness.py b/tests/core/agent/test_request_language_harness.py index f3f99d4e8f..3fbdb308d4 100644 --- a/tests/core/agent/test_request_language_harness.py +++ b/tests/core/agent/test_request_language_harness.py @@ -5,8 +5,9 @@ import pytest -from xagent.core.agent.context import ExecutionContext +from xagent.core.agent.context import CompactConfig, ExecutionContext from xagent.core.agent.context.enrichment import ( + TOP_LEVEL_USER_REQUEST_METADATA_KEY, latest_user_text, top_level_user_request, ) @@ -14,13 +15,16 @@ OUTPUT_LANGUAGE_METADATA_KEY, final_answer_language_rule, output_language_directives, + output_language_policy, request_only_language_harness, ) from xagent.core.agent.pattern.auto.auto import AutoPattern from xagent.core.agent.pattern.dag.dag import DAGPattern from xagent.core.agent.pattern.dag.plan_generator import ( + ExecutionPlan, LLMPlanGenerator, PlanGenerationRequest, + PlanStep, ) from xagent.core.agent.pattern.react.react import ReActPattern @@ -91,11 +95,65 @@ def test_request_language_harness_preserves_soft_authority_invariants() -> None: harness = request_only_language_harness("Summarize this request.") assert "Request-only response language harness" in harness - assert "explicit and implicit requests" in harness + assert "explicit or implicit target-language intent" in harness + assert "explicitly marked as the answer to a pending agent question" in harness assert "empty, too short, mixed-language, or depends on conversation" in harness assert "Output language:" not in harness +def test_every_unpinned_surface_uses_one_noncontradictory_language_policy() -> None: + context = _polluted_context() + root, plan_payload, completion_payload = _language_surfaces(context) + missing_display = ExecutionContext(execution_id="missing-display-policy") + missing_display.add_user_message(ENGLISH_REQUEST) + final_rule = final_answer_language_rule() + canonical_surfaces = [ + root, + missing_display._system_context(), + str(plan_payload["output_language_policy"]), + str(completion_payload["output_language_policy"]), + output_language_policy(), + final_rule, + ] + forbidden = ( + "unless the current user request explicitly asks for that language change", + "unless the user-authored request above explicitly asks", + "unless the `latest_user_request` field explicitly asks", + "unless the `user_authored_language_request` field explicitly asks", + ) + + for surface in canonical_surfaces: + assert "explicit or implicit target-language intent" in surface + assert "explicitly marked as the answer to a pending agent question" in surface + assert all(clause not in surface for clause in forbidden) + + planner_description = LLMPlanGenerator()._plan_tool_schema()["function"][ + "parameters" + ]["properties"]["response_language"]["description"] + assert "explicit or implicit target-language intent" in planner_description + assert "pending_agent_question_response" in planner_description + retry_context = ExecutionContext(execution_id="language-retry-policy") + retry_context.add_user_message("请用中文总结。") + retry = LLMPlanGenerator._request_language_reminder( + retry_context, + ExecutionPlan(steps=[PlanStep(id="summary", task="Write an English summary")]), + ) + assert retry is not None + assert "explicit or implicit target-language intent" in retry + assert "explicitly marked as a pending agent question response" in retry + assert "from that request alone" not in retry + assert ( + AutoPattern() + ._decision_tool_schema()["function"]["description"] + .endswith(final_rule) + ) + assert ( + ReActPattern() + ._final_answer_tool_schema()["function"]["description"] + .endswith(final_rule) + ) + + def test_root_language_harness_uses_only_the_user_authored_request() -> None: system_context = _polluted_context()._system_context() harness = system_context.split("Request-only response language harness:\n", 1)[1] @@ -187,6 +245,25 @@ def test_dag_language_consumers_receive_the_same_user_authored_request() -> None assert "Gerard Santos" not in completion_payload["output_language_policy"] +def test_new_independent_request_replaces_the_persisted_provenance() -> None: + context = _polluted_context() + assert top_level_user_request(context).language_text == ENGLISH_REQUEST + + follow_up = "Ahora responde en español." + context.add_user_message( + f"{follow_up}\n[Connector context in English]", + metadata={"display_message": follow_up}, + ) + + request = top_level_user_request(context) + assert request.language_text == follow_up + assert context.metadata[TOP_LEVEL_USER_REQUEST_METADATA_KEY] == { + "execution_text": f"{follow_up}\n[Connector context in English]", + "language_text": follow_up, + "display_state": "text", + } + + def test_structured_language_payloads_include_a_large_request_exactly_once() -> None: request = "LANGUAGE_SENTINEL_BEGIN_" + "背景" * 8_000 + "_LANGUAGE_SENTINEL_END" context = ExecutionContext(execution_id="large-request-language") @@ -297,6 +374,70 @@ def test_dag_step_preserves_authoritative_blank_display_anchor( ) +@pytest.mark.parametrize( + ("display_message", "language_text", "display_state"), + [ + pytest.param(ENGLISH_REQUEST, ENGLISH_REQUEST, "text", id="clean-display"), + pytest.param("", "", "empty", id="blank-display"), + pytest.param(" \n\t", "", "empty", id="whitespace-display"), + ], +) +@pytest.mark.parametrize("compaction", ["fresh", "summary", "truncate"]) +@pytest.mark.parametrize("cold_restore", [False, True], ids=["live", "restored"]) +def test_dag_request_provenance_survives_compaction_and_restore( + display_message: str, + language_text: str, + display_state: str, + compaction: str, + cold_restore: bool, +) -> None: + root = ExecutionContext( + execution_id="dag-provenance-root", + metadata={"task": POLLUTED_EXECUTION_REQUEST}, + ) + root.add_user_message( + POLLUTED_EXECUTION_REQUEST, + metadata={"display_message": display_message}, + ) + child = root.create_child_context( + execution_id="dag-provenance-step", + metadata={"dag_step_id": "draft", "dag_step_name": "Redactar respuesta"}, + ) + child.add_user_message( + "DAG step instruction in Spanish", + metadata={"dag_step_id": "draft", "kind": "dag_step_instruction"}, + ) + + if compaction == "summary": + child.compact_with_llm_response({"content": "Resumen español del trabajo"}) + elif compaction == "truncate": + child.compact_config = CompactConfig( + enabled=True, + threshold=1, + max_messages=1, + ) + assert child.compact_if_needed().compacted + if cold_restore: + child = ExecutionContext.from_dict(child.to_dict()) + + request = top_level_user_request(child) + snapshot = child.metadata[TOP_LEVEL_USER_REQUEST_METADATA_KEY] + system_context = child._system_context() + + assert request.execution_text == POLLUTED_EXECUTION_REQUEST + assert request.language_text == language_text + assert request.display_state == display_state + assert snapshot == { + "execution_text": POLLUTED_EXECUTION_REQUEST, + "language_text": language_text, + "display_state": display_state, + } + assert request_only_language_harness(language_text) in system_context + assert ( + request_only_language_harness(POLLUTED_EXECUTION_REQUEST) not in system_context + ) + + def _waiting_dag_context(answer: str) -> tuple[DAGPattern, ExecutionContext]: root = ExecutionContext(execution_id="dag-language-wait") root.add_user_message( @@ -367,7 +508,15 @@ def test_dag_wait_response_keeps_top_level_language_boundary( item for item in completion["messages"] if item.get("content") == answer ) assert answer_payload["user_message_context"] == "pending_agent_question_response" - assert "unless that answer explicitly asks" in completion["output_language_policy"] + policies = [ + root._system_context(), + str(plan_payload["output_language_policy"]), + str(completion["output_language_policy"]), + ] + for policy in policies: + assert "explicitly marked as the answer to a pending agent question" in policy + assert "only when the marked answer explicitly asks" in policy + assert "unless the user-authored request above explicitly asks" not in policy def test_large_split_request_has_canonical_provider_copies_and_budget() -> None: @@ -403,6 +552,8 @@ def test_caller_pinned_language_remains_the_only_hard_authority() -> None: ) assert "Output language: French" in completion_payload["output_language_policy"] assert "user_authored_language_request" not in completion_payload + assert "sole hard authority" in output_language_policy("French") + assert "user-requested language changes" not in output_language_policy("French") def test_final_answer_schemas_follow_the_shared_language_guidance() -> None: diff --git a/tests/core/tools/test_create_agent_tool.py b/tests/core/tools/test_create_agent_tool.py index c97d4ad9d7..e4188ba208 100644 --- a/tests/core/tools/test_create_agent_tool.py +++ b/tests/core/tools/test_create_agent_tool.py @@ -157,22 +157,23 @@ class TestCreateAgentTool: def test_create_agent_tool_schema_anchors_persisted_text_language(self) -> None: tool = CreateAgentTool(session_factory=None, user_id=1) - assert ( - "same natural language as the current output language policy" - in tool.description + assert "language required by the current output language policy" in ( + tool.description ) + assert "unless the user explicitly asks" not in tool.description assert "Do not inherit another language from DAG step text" in tool.description schema = tool.args_type().model_json_schema() description_schema = schema["properties"]["description"]["description"] instructions_schema = schema["properties"]["instructions"]["description"] - assert ( - "same natural language as the current output language policy" - in description_schema + assert "language required by the current output language policy" in ( + description_schema ) - assert ( - "same natural language as the current output language policy" - in instructions_schema + assert "language required by the current output language policy" in ( + instructions_schema + ) + assert "unless the user explicitly asks" not in ( + description_schema + instructions_schema ) @pytest.mark.asyncio From 9134be2e0b70bcaff132f1cbc17dd45cc6beea51 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Wed, 2 Sep 2026 17:29:09 +0800 Subject: [PATCH 09/10] chore(tests): avoid codespell false positive --- tests/core/agent/test_request_language_harness.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/agent/test_request_language_harness.py b/tests/core/agent/test_request_language_harness.py index 3fbdb308d4..8543e845cf 100644 --- a/tests/core/agent/test_request_language_harness.py +++ b/tests/core/agent/test_request_language_harness.py @@ -249,7 +249,7 @@ def test_new_independent_request_replaces_the_persisted_provenance() -> None: context = _polluted_context() assert top_level_user_request(context).language_text == ENGLISH_REQUEST - follow_up = "Ahora responde en español." + follow_up = "Switch to Spanish now." context.add_user_message( f"{follow_up}\n[Connector context in English]", metadata={"display_message": follow_up}, From c1a68f6f646d99089a4b92172732a2f244bf26d4 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Wed, 2 Sep 2026 17:51:52 +0800 Subject: [PATCH 10/10] test(web): align instruction language policy --- tests/web/api/test_agents_optimize_instructions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/web/api/test_agents_optimize_instructions.py b/tests/web/api/test_agents_optimize_instructions.py index a6e9933465..1f8dbeb02f 100644 --- a/tests/web/api/test_agents_optimize_instructions.py +++ b/tests/web/api/test_agents_optimize_instructions.py @@ -54,7 +54,11 @@ async def test_optimize_instructions_preserves_draft_language( } system_prompt = llm.calls[0]["messages"][0]["content"] assert "Preserve the draft's natural language" in system_prompt - assert "same natural language as the draft instructions" in system_prompt + assert ( + "Use the draft instructions as the baseline language authority" in system_prompt + ) + assert "explicit or implicit target-language intent" in system_prompt + assert "unless the draft instructions explicitly asks" not in system_prompt assert "Simplified Chinese versus Traditional Chinese" in system_prompt