From 22c389edc16149828380c7153c0b492400f66a5f Mon Sep 17 00:00:00 2001 From: leonoxo Date: Sat, 8 Aug 2026 22:29:54 +0800 Subject: [PATCH] fix(pipeline): ground local-agent system prompt with current date (#2399) The local-agent runner's system prompt is a static string with no template-variable support, so the model had no anchor for "today" and resolved relative time references (e.g. "this quarter", "latest") against whichever period was best represented in training data instead of the real date, sometimes confidently answering with stale information for time-sensitive questions. PreProcessor now appends a short, deterministically-computed "Current date: ..." note to the system prompt on every request for local-agent pipelines, alongside guidance to verify time-sensitive facts with a search tool rather than answering from memory. The existing skill-awareness prompt injection is refactored to share the same append-to-system-prompt helper. --- src/langbot/pkg/pipeline/preproc/preproc.py | 62 +++++++---- tests/unit_tests/pipeline/test_preproc.py | 112 ++++++++++++++++++++ 2 files changed, 153 insertions(+), 21 deletions(-) diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 5b2201b2e..f2d9fe9a1 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -41,6 +41,29 @@ class PreProcessor(stage.PipelineStage): selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)} return [tool for tool in tools if tool.name in selected_tool_names] + @staticmethod + def _append_to_system_prompt( + messages: list[provider_message.Message], + addition: str, + ) -> None: + """Append text to the first system message, creating one if none exists. + + Handles both plain-string and content-element (list) message bodies. + """ + if messages and messages[0].role == 'system': + head = messages[0] + if isinstance(head.content, str): + head.content = head.content + addition + elif isinstance(head.content, list): + for ce in head.content: + if getattr(ce, 'type', None) == 'text': + ce.text = (ce.text or '') + addition + break + else: + head.content.append(provider_message.ContentElement(type='text', text=addition)) + else: + messages.insert(0, provider_message.Message(role='system', content=addition.strip())) + async def process( self, query: pipeline_query.Query, @@ -275,6 +298,23 @@ class PreProcessor(stage.PipelineStage): query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt + # =========== Current date grounding for the local-agent runner =========== + # local-agent system prompts are static strings with no template-variable + # support, so without an explicit anchor the LLM resolves relative time + # references (e.g. "this quarter", "latest", "currently") against whichever + # period is best represented in its training data instead of the real date, + # and won't reliably know to double check time-sensitive facts with a tool. + if selected_runner == 'local-agent': + date_addition = ( + f'\n\nCurrent date: {datetime.datetime.now().strftime("%Y-%m-%d (%A)")}. ' + 'Resolve relative time references (e.g. "today", "this quarter", "latest", ' + '"currently") based on this date, not your training cutoff. For anything ' + 'time-sensitive that may have changed since training — stock prices, ' + 'financial results, news, current events, exchange rates, or similar — ' + 'verify with a search tool if one is available rather than answering from memory.' + ) + self._append_to_system_prompt(query.prompt.messages, date_addition) + # =========== Skill awareness for the local-agent runner =========== # The actual activation goes through the ``activate`` Tool Call so the # LLM doesn't see full SKILL.md instructions until it commits to a @@ -310,27 +350,7 @@ class PreProcessor(stage.PipelineStage): bound_skills=bound_skills, ) if skill_addition: - # Append to the first system message; create one if the - # prompt has none. Handles both plain-string and - # content-element (list) message bodies. - if query.prompt.messages and query.prompt.messages[0].role == 'system': - head = query.prompt.messages[0] - if isinstance(head.content, str): - head.content = head.content + skill_addition - elif isinstance(head.content, list): - appended = False - for ce in head.content: - if getattr(ce, 'type', None) == 'text': - ce.text = (ce.text or '') + skill_addition - appended = True - break - if not appended: - head.content.append(provider_message.ContentElement(type='text', text=skill_addition)) - else: - query.prompt.messages.insert( - 0, - provider_message.Message(role='system', content=skill_addition.strip()), - ) + self._append_to_system_prompt(query.prompt.messages, skill_addition) self.ap.logger.debug( f'Skill index injected into system prompt: ' f'pipeline={query.pipeline_uuid} ' diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index c28858959..393418ee0 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -488,3 +488,115 @@ class TestPreProcessorToolSelection: result = await stage.process(query, 'PreProcessor') assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool'] + + +class TestPreProcessorDateGrounding: + """Tests for current-date injection into the local-agent system prompt.""" + + @pytest.mark.asyncio + async def test_local_agent_appends_date_to_existing_system_message(self): + """Date grounding text should be appended to an existing system prompt.""" + preproc = get_preproc_module() + + app = FakeApp() + mock_session = make_session() + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + + from langbot_plugin.api.entities.builtin.provider import message as provider_message + + system_message = provider_message.Message(role='system', content='You are a helpful assistant.') + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[system_message], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = preproc.PreProcessor(app) + query = text_query('hello') + + result = await stage.process(query, 'PreProcessor') + + messages = result.new_query.prompt.messages + assert len(messages) == 1 + assert messages[0].role == 'system' + assert messages[0].content.startswith('You are a helpful assistant.') + assert 'Current date:' in messages[0].content + + @pytest.mark.asyncio + async def test_local_agent_creates_system_message_when_none_exists(self): + """A system message should be created when the prompt has none.""" + preproc = get_preproc_module() + + app = FakeApp() + mock_session = make_session() + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = preproc.PreProcessor(app) + query = text_query('hello') + + result = await stage.process(query, 'PreProcessor') + + messages = result.new_query.prompt.messages + assert len(messages) == 1 + assert messages[0].role == 'system' + assert 'Current date:' in messages[0].content + + @pytest.mark.asyncio + async def test_non_local_agent_runner_skips_date_injection(self): + """Runners other than local-agent should not get the date addition.""" + preproc = get_preproc_module() + + app = FakeApp() + mock_session = make_session() + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = preproc.PreProcessor(app) + query = text_query('hello') + query.pipeline_config = { + 'ai': { + 'runner': {'runner': 'dify-service-api'}, + 'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': 'default'}, + }, + 'output': {'misc': {'at-sender': False}}, + 'trigger': {'misc': {}}, + } + + result = await stage.process(query, 'PreProcessor') + + assert result.new_query.prompt.messages == []