diff --git a/backend/services/agent_automation/intent_analyzer.py b/backend/services/agent_automation/intent_analyzer.py index 8a68ca4f5..b9bc0c136 100644 --- a/backend/services/agent_automation/intent_analyzer.py +++ b/backend/services/agent_automation/intent_analyzer.py @@ -273,6 +273,7 @@ async def analyze(self, context: AutomationIntentContext) -> Dict[str, Any]: def _generate_sync(self, context: AutomationIntentContext) -> str: from nexent.core.models import OpenAIModel + from nexent.core.utils.observer import MessageObserver from utils.config_utils import get_model_name_from_config language = detect_instruction_language(context.message) @@ -289,6 +290,7 @@ def _generate_sync(self, context: AutomationIntentContext) -> str: undefined=StrictUndefined, ).render(**values).strip() llm = OpenAIModel( + observer=MessageObserver(), model_id=get_model_name_from_config(self._model_config), api_base=self._model_config.get("base_url", ""), api_key=self._model_config.get("api_key", ""), diff --git a/backend/services/agent_automation/prompt_generator.py b/backend/services/agent_automation/prompt_generator.py index b1455f886..2d75744ca 100644 --- a/backend/services/agent_automation/prompt_generator.py +++ b/backend/services/agent_automation/prompt_generator.py @@ -227,12 +227,14 @@ def _generate_sync( user_key: str, ) -> str: from nexent.core.models import OpenAIModel + from nexent.core.utils.observer import MessageObserver from utils.config_utils import get_model_name_from_config prompt_template = get_prompt_template("agent_automation", context.language) values = {"instruction": context.instruction.strip()} user_prompt = Template(prompt_template[user_key], undefined=StrictUndefined).render(**values).strip() llm = OpenAIModel( + observer=MessageObserver(), model_id=get_model_name_from_config(self._model_config) if self._model_config.get("model_name") else "", api_base=self._model_config.get("base_url", ""), api_key=self._model_config.get("api_key", ""), diff --git a/backend/services/agent_automation/tool_adapter.py b/backend/services/agent_automation/tool_adapter.py index d55269e6b..5aa75f207 100644 --- a/backend/services/agent_automation/tool_adapter.py +++ b/backend/services/agent_automation/tool_adapter.py @@ -34,6 +34,16 @@ DEFAULT_AUTOMATION_TIMEZONE = "Asia/Shanghai" +def _strip_runtime_time_prefix(message: str) -> str: + """Remove the runtime-only current-time header from the user request.""" + normalized = str(message or "") + if normalized.startswith("[Current time:"): + close_index = normalized.find("]", len("[Current time:")) + if close_index >= 0: + return normalized[close_index + 1:].lstrip("\n").strip() + return normalized + + def _run_coroutine(coro): try: asyncio.get_running_loop() @@ -130,7 +140,8 @@ async def create_proposal( # The model argument is intentionally not forwarded to extraction. The # persisted current user message is the authoritative business input. del request_text - language = detect_instruction_language(context.user_message) + user_message = _strip_runtime_time_prefix(context.user_message) + language = detect_instruction_language(user_message) if context.source_message_id is None: message = ( "本轮消息尚未完成持久化,无法安全创建定时任务提案。请稍后重试。" @@ -160,7 +171,7 @@ async def create_proposal( request = AutomationProposalCreateRequest( conversation_id=context.conversation_id, agent_id=context.agent_id, - message=context.user_message, + message=user_message, timezone=context.timezone or DEFAULT_AUTOMATION_TIMEZONE, agent_version_no=context.agent_version_no, model_id=context.model_id, diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index 5af958dbf..3e2f7918d 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -215,6 +215,9 @@ class InvalidActionFormatError(AgentExecutionError): r"|(?:我(?:将|需要|先)|接下来|下一步|i\s+(?:will|need\s+to|should)\b|next\b).{0,240})" r"(?:调用|使用|检索|搜索|call|use|search|invoke)" ) +_EXPLICIT_FINAL_ANSWER_RE = re.compile( + r"(?is)(?:^|\n)\s*(?:最终回答|final\s+answer)\s*[::]\s*\S" +) def _looks_like_invalid_action_output(text: Any) -> bool: @@ -263,6 +266,8 @@ def _looks_like_incomplete_action_output( return True if _looks_like_invalid_action_output(text): return True + if _EXPLICIT_FINAL_ANSWER_RE.search(text): + return False normalized = text.casefold() mentioned_tool = any( diff --git a/test/backend/services/test_agent_automation_intent_analyzer.py b/test/backend/services/test_agent_automation_intent_analyzer.py index 30576a160..6ac2b0a91 100644 --- a/test/backend/services/test_agent_automation_intent_analyzer.py +++ b/test/backend/services/test_agent_automation_intent_analyzer.py @@ -220,6 +220,8 @@ def test_llm_analyzer_generate_sync_invokes_model_as_callable(monkeypatch): "schedule_error": None, }, ensure_ascii=False) + captured = {} + class FakeModel: def __init__(self): self.calls = None @@ -229,7 +231,12 @@ def __call__(self, messages): return SimpleNamespace(content=fake_payload) fake_model = FakeModel() - monkeypatch.setattr("nexent.core.models.OpenAIModel", lambda **kwargs: fake_model) + + def build_fake_model(**kwargs): + captured["config"] = kwargs + return fake_model + + monkeypatch.setattr("nexent.core.models.OpenAIModel", build_fake_model) monkeypatch.setattr( "services.agent_automation.intent_analyzer.get_prompt_template", lambda *a, **k: { @@ -251,6 +258,9 @@ def __call__(self, messages): assert result["analysis_source"] == "llm" assert result["is_automation_intent"] is True assert result["schedule_trigger"].cron_expr == "0 8 * * *" + from nexent.core.utils.observer import MessageObserver + + assert isinstance(captured["config"]["observer"], MessageObserver) assert fake_model.calls[0] == {"role": "system", "content": "sys"} assert fake_model.calls[1]["content"].startswith("msg: ") diff --git a/test/backend/services/test_agent_automation_prompt_generator.py b/test/backend/services/test_agent_automation_prompt_generator.py index 5b8d46023..72b0a44bc 100644 --- a/test/backend/services/test_agent_automation_prompt_generator.py +++ b/test/backend/services/test_agent_automation_prompt_generator.py @@ -305,6 +305,9 @@ def __call__(self, messages): assert result == '{"title":"A","instruction":"B"}' assert captured["config"]["model_id"] == "resolved-model" assert captured["config"]["ssl_verify"] is False + from nexent.core.utils.observer import MessageObserver + + assert isinstance(captured["config"]["observer"], MessageObserver) assert captured["messages"][1]["content"] == "Instruction: do work" diff --git a/test/backend/services/test_agent_automation_tool_adapter.py b/test/backend/services/test_agent_automation_tool_adapter.py index 622e0dfab..16814e7da 100644 --- a/test/backend/services/test_agent_automation_tool_adapter.py +++ b/test/backend/services/test_agent_automation_tool_adapter.py @@ -6,6 +6,7 @@ from services.agent_automation.tool_adapter import ( AgentLoopAutomationToolAdapter, AutomationToolRuntimeContext, + _strip_runtime_time_prefix, link_persisted_proposal_card, ) @@ -36,6 +37,24 @@ def test_build_tool_config_registers_scheduled_task_as_builtin(monkeypatch): assert callable(tool_config.metadata["create_proposal"]) +@pytest.mark.parametrize( + ("message", "expected"), + [ + ( + "[Current time: 2026-08-26 09:20:28]\n\n每天早上9点查一下八字信息", + "每天早上9点查一下八字信息", + ), + ( + "[Current time: 2026-08-26 09:20:28]\n\nEvery day at 9 AM, check the report", + "Every day at 9 AM, check the report", + ), + ("每天九点生成日报", "每天九点生成日报"), + ], +) +def test_strip_runtime_time_prefix_keeps_only_the_original_request(message, expected): + assert _strip_runtime_time_prefix(message) == expected + + def test_build_callback_runs_coroutine_without_an_event_loop(monkeypatch): async def fake_create_proposal(context, request_text): return {"request_text": request_text, "conversation_id": context.conversation_id} @@ -101,7 +120,7 @@ async def fake_create_proposal(request, tenant_id, user_id, **kwargs): user_id="user-1", conversation_id=20, agent_id=7, - user_message="每天九点生成日报", + user_message="[Current time: 2026-08-26 09:20:28]\n\n每天九点生成日报", source_message_id=101, model_id=3, ) diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py index a5928ebce..4ec8ca50c 100644 --- a/test/sdk/core/agents/test_core_agent.py +++ b/test/sdk/core/agents/test_core_agent.py @@ -375,12 +375,33 @@ def test_complete_answer_that_names_tool_is_not_misclassified(): ) is False +@pytest.mark.parametrize( + "output", + [ + ( + "思考:工具调用成功。根据策略,我需要用 `final_answer` 返回工具结果。\n\n" + "最终回答:\n定时任务提案已生成,请核对任务内容和执行时间后确认创建。" + ), + ( + "Analysis: The tool call succeeded, so I will use `final_answer` to return the result.\n\n" + "Final answer:\nThe scheduled-task proposal is ready for confirmation." + ), + ], +) +def test_complete_explicit_final_answer_is_not_misclassified(output): + assert core_agent_module._looks_like_incomplete_action_output( + output, + available_tool_names={"final_answer", "create_scheduled_task_proposal"}, + ) is False + + def test_length_truncated_non_code_output_is_not_a_final_answer(): assert core_agent_module._looks_like_incomplete_action_output( "这是一个尚未完成的回答", finish_reason="length", ) is True + def test_parse_code_blobs_run_format(): """Test parse_code_blobs with ... pattern (new format).""" text = """Here is some code: