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 == []