From 249ed86b578db0b1043b3b855bd2681752662474 Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Wed, 26 Aug 2026 21:19:17 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E4=B8=BB?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E7=B3=BB=E7=BB=9F=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E7=BB=84=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Codex --- app/ai/prompts/agent_loop.py | 21 +- app/ai/prompts/system_prompt.py | 89 +++++++ app/services/chat/message_builder.py | 23 +- .../stream/agent_loop_request_prep.py | 61 +++-- test/services/chat/test_context_manager.py | 4 +- test/services/chat/test_message_builder.py | 6 + .../stream/test_agent_loop_request_prep.py | 220 ++++++++++++++---- test/test_prompt_runtime_templates.py | 33 ++- test/test_system_prompt_assembly.py | 45 ++++ 9 files changed, 399 insertions(+), 103 deletions(-) create mode 100644 app/ai/prompts/system_prompt.py create mode 100644 test/test_system_prompt_assembly.py diff --git a/app/ai/prompts/agent_loop.py b/app/ai/prompts/agent_loop.py index 2babd124..c34478d9 100644 --- a/app/ai/prompts/agent_loop.py +++ b/app/ai/prompts/agent_loop.py @@ -1,6 +1,6 @@ """Agent loop 提示词常量。 -当前先以代码常量维护,待搜索/深读策略稳定后迁移到 PromptHub。 +主聊天规则以代码维护;摘要和工具说明保留现有解析路径。 """ from __future__ import annotations @@ -16,6 +16,7 @@ def build_current_date_system_prompt(now: datetime | None = None) -> str: """为 LLM 注入当前真实日期,避免模型凭训练 cutoff 猜年份。""" current = now or datetime.now(CHINA_TZ) + current = current.replace(tzinfo=CHINA_TZ) if current.tzinfo is None else current.astimezone(CHINA_TZ) weekday_cn = ["一", "二", "三", "四", "五", "六", "日"][current.weekday()] tomorrow = current.date() + timedelta(days=1) week_start = current.date() - timedelta(days=current.weekday()) @@ -23,7 +24,6 @@ def build_current_date_system_prompt(now: datetime | None = None) -> str: this_sunday = week_start + timedelta(days=6) next_saturday = this_saturday + timedelta(days=7) next_sunday = this_sunday + timedelta(days=7) - forbidden_years = ", ".join(str(y) for y in range(current.year - 3, current.year)) return ( f"【当前真实日期】{current.year}年{current.month}月{current.day}日(星期{weekday_cn})," f"北京时间 {current.strftime('%H:%M')}。\n\n" @@ -35,8 +35,7 @@ def build_current_date_system_prompt(now: datetime | None = None) -> str: f"下周日是 {_format_date_with_weekday(next_sunday)}。\n\n" f"硬性规则:\n" f"1. 涉及『最新』『当前』『目前』等时效性问题,必须基于上述日期作答。\n" - f"2. **生成搜索关键词时,年份必须使用 {current.year} 或更晚," - f"严禁使用 {forbidden_years} 等过去年份**。\n" + f"2. 搜索年份应匹配用户问题;历史事件或指定年份查询应保留对应历史年份,不得强制改为当前年份。\n" f"3. 不要相信训练数据中的『当前年份』印象——你的训练 cutoff 早于现在。\n" f"4. 用户使用『今天』『明天』『本周末』『下周末』等相对日期时," f"必须先按上述锚点换算;搜索词与最终答案中的日期、星期必须一致。" @@ -262,25 +261,25 @@ def get_runtime_prompt_template(name: str, fallback: str) -> str: def get_app_identity_prompt() -> str: - return get_runtime_prompt_template("app_identity", APP_IDENTITY_PROMPT) + return APP_IDENTITY_PROMPT def get_tool_usage_contract_prompt() -> str: - return get_runtime_prompt_template("tool_usage_contract", TOOL_USAGE_CONTRACT_PROMPT) + return TOOL_USAGE_CONTRACT_PROMPT def get_no_tool_network_boundary_prompt() -> str: - return get_runtime_prompt_template("no_tool_network_boundary", NO_TOOL_NETWORK_BOUNDARY_PROMPT) + return NO_TOOL_NETWORK_BOUNDARY_PROMPT def get_agent_plan_control_prompt(plan_mode: str) -> str: if plan_mode == "on": - return get_runtime_prompt_template("agent_plan_control_on", AGENT_PLAN_CONTROL_ON_PROMPT) - return get_runtime_prompt_template("agent_plan_control_auto", AGENT_PLAN_CONTROL_AUTO_PROMPT) + return AGENT_PLAN_CONTROL_ON_PROMPT + return AGENT_PLAN_CONTROL_AUTO_PROMPT def get_no_vision_file_boundary_prompt() -> str: - return get_runtime_prompt_template("no_vision_file_boundary", NO_VISION_FILE_BOUNDARY_PROMPT) + return NO_VISION_FILE_BOUNDARY_PROMPT def get_url_read_tool_description() -> str: @@ -292,4 +291,4 @@ def get_limit_summary_prompt() -> str: def get_continuation_system_prompt() -> str: - return get_runtime_prompt_template("continuation_system", CONTINUATION_SYSTEM_PROMPT) + return CONTINUATION_SYSTEM_PROMPT diff --git a/app/ai/prompts/system_prompt.py b/app/ai/prompts/system_prompt.py new file mode 100644 index 00000000..bbf5a83a --- /dev/null +++ b/app/ai/prompts/system_prompt.py @@ -0,0 +1,89 @@ +"""按可信段落身份组装系统提示词;仅处理内存数据,不访问服务或外部 IO。""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from time import perf_counter +from typing import Any + +from app.ai.prompts.agent_loop import build_current_date_system_prompt, get_app_identity_prompt +from app.utils.prompt_fingerprint import fingerprint_system_messages + +TEMPLATE_VERSION = "2026-08-26.1" + + +@dataclass(frozen=True) +class SystemPromptSection: + section_id: str + content: str + + +@dataclass(frozen=True) +class SystemPromptAssembly: + messages: list[dict] + metadata: dict[str, Any] + + +class SystemPromptAssemblyError(Exception): + """仅向调用方提供固定安全错误信息和组装元数据。""" + + def __init__(self, metadata: dict[str, Any]): + super().__init__("系统提示词组装失败") + self.metadata = metadata + + +def build_base_sections(user_system_prompt: str | None = None) -> list[SystemPromptSection]: + sections = [SystemPromptSection("current_date", build_current_date_system_prompt())] + if user_system_prompt and user_system_prompt.strip(): + sections.append( + SystemPromptSection( + "user_preferences", + "以下是用户的个性化偏好设置,请在回答中自然遵守,但不要主动提及这些设置本身:\n\n" + + user_system_prompt.strip(), + ) + ) + sections.append(SystemPromptSection("app_identity", get_app_identity_prompt())) + return sections + + +def assemble_system_prompt( + *, + user_system_prompt: str | None = None, + sections: Callable[[], Iterable[SystemPromptSection]] | None = None, +) -> SystemPromptAssembly: + """回调只允许读取已准备的内存状态,段落内容不参与身份判定。""" + started = perf_counter() + section_ids: list[str] = [] + metadata: dict[str, Any] = { + "status": "failed", + "source": "code", + "template_version": TEMPLATE_VERSION, + "section_ids": section_ids, + "fingerprint": None, + "char_count": None, + } + try: + selected = build_base_sections(user_system_prompt) + if sections is not None: + selected.extend(sections()) + messages = [] + for section in selected: + if section.section_id in section_ids: + continue + section_ids.append(section.section_id) + messages.append({"role": "system", "content": section.content}) + metadata.update( + status="ready", + fingerprint=fingerprint_system_messages(messages), + char_count=sum(len(message["content"]) for message in messages), + duration_ms=max(0, int((perf_counter() - started) * 1000)), + ) + return SystemPromptAssembly(messages=messages, metadata=metadata) + except Exception: + metadata.update( + duration_ms=max(0, int((perf_counter() - started) * 1000)), + error_code="assembly_failed", + message="系统提示词组装失败", + ) + raise SystemPromptAssemblyError(metadata) from None diff --git a/app/services/chat/message_builder.py b/app/services/chat/message_builder.py index 1bc74ece..f4aeb759 100644 --- a/app/services/chat/message_builder.py +++ b/app/services/chat/message_builder.py @@ -10,7 +10,7 @@ import base64 from typing import Dict, List, Optional -from app.ai.prompts.agent_loop import build_current_date_system_prompt, get_app_identity_prompt +from app.ai.prompts.system_prompt import build_base_sections from app.core.logger import app_logger as logger from app.db.repositories import FileRepository from app.services.file_service import is_image_mime @@ -66,6 +66,7 @@ async def build_llm_messages( *, user_id: Optional[str] = None, conversation_id: Optional[str] = None, + include_base_system: bool = True, ) -> List[dict]: """ 将 content blocks 消息列表转为 LLM 可消费的 dict 格式。 @@ -79,24 +80,12 @@ async def build_llm_messages( """ result = [] - # 注入当前日期(始终注入,对所有对话生效) - result.append({"role": "system", "content": build_current_date_system_prompt()}) - - # 注入用户自定义个性化 prompt - if user_system_prompt and user_system_prompt.strip(): - result.append( - { - "role": "system", - "content": ( - "以下是用户的个性化偏好设置,请在回答中自然遵守,但不要主动提及这些设置本身:\n\n" - f"{user_system_prompt.strip()}" - ), - } + # 主聊天由纯组装器统一注入;其他调用保持基础规则保证。 + if include_base_system: + result.extend( + {"role": "system", "content": section.content} for section in build_base_sections(user_system_prompt) ) - # 注入产品身份约束(始终注入,并放在用户偏好之后,避免被个性化设置覆盖) - result.append({"role": "system", "content": get_app_identity_prompt()}) - # 计算最近 N 轮用户消息的起始索引,用于控制图片注入范围 vision_cutoff_idx = 0 if has_vision: diff --git a/app/services/stream/agent_loop_request_prep.py b/app/services/stream/agent_loop_request_prep.py index 57d343b2..bdbc45b2 100644 --- a/app/services/stream/agent_loop_request_prep.py +++ b/app/services/stream/agent_loop_request_prep.py @@ -14,6 +14,7 @@ get_no_vision_file_boundary_prompt, get_tool_usage_contract_prompt, ) +from app.ai.prompts.system_prompt import SystemPromptSection, assemble_system_prompt from app.ai.tools import build_url_read_tool, build_web_search_tool from app.db.repositories import FileRepository from app.services.agent.plan_coordinator import PlanMode @@ -128,6 +129,7 @@ class AgentLoopPreparedMessages: messages: list[dict] initial_content_blocks: list[Any] = field(default_factory=list) final_tool_names: list[str] = field(default_factory=list) + prompt_assembly: dict[str, Any] | None = None def announced_tool_names_from_call_kwargs(call_kwargs: dict) -> list[str]: @@ -338,11 +340,11 @@ async def prepare_agent_loop_messages( raw_messages, has_vision, file_repo, - user_system_prompt, + None, + include_base_system=False, user_id=user_id, conversation_id=conversation_id, ) - messages = inject_extra_system_prompts(messages, extra_system_prompts or []) if preprocess_user_input: has_image_attachment = _has_image_file( @@ -372,18 +374,31 @@ async def prepare_agent_loop_messages( initial_content_blocks = [] has_image_attachment = False - if has_image_attachment and not has_vision: - messages = inject_no_vision_file_boundary(messages) - messages = inject_tool_usage_contract(messages, call_config.call_kwargs) - messages = inject_amap_fact_boundary(messages, call_config.call_kwargs) - messages = inject_flyai_travel_fact_boundary(messages, call_config.call_kwargs) - messages = inject_plan_control_contract(messages, call_config) - messages = inject_verified_research_plan_contract(messages, call_config) - messages = inject_deep_research_contract(messages, call_config) - messages = inject_no_tool_network_boundary(messages, call_config.call_kwargs) + def selected_sections(): + # 只在空消息集上选择可信模板,用户文本不会影响段落是否存在。 + if has_image_attachment and not has_vision: + yield SystemPromptSection("no_vision_file_boundary", get_no_vision_file_boundary_prompt()) + selectors = [ + ("tool_usage_contract", inject_tool_usage_contract, call_config.call_kwargs), + ("amap_fact_boundary", inject_amap_fact_boundary, call_config.call_kwargs), + ("flyai_travel_fact_boundary", inject_flyai_travel_fact_boundary, call_config.call_kwargs), + ("agent_plan_control", inject_plan_control_contract, call_config), + ("verified_research_plan", inject_verified_research_plan_contract, call_config), + ("deep_research_contract", inject_deep_research_contract, call_config), + ("no_tool_network_boundary", inject_no_tool_network_boundary, call_config.call_kwargs), + ] + for index, prompt in enumerate(extra_system_prompts or []): + yield SystemPromptSection(f"extra_system_{index}", prompt) + for section_id, selector, config in selectors: + for message in selector([], config): + yield SystemPromptSection(section_id, message["content"]) + + assembly = assemble_system_prompt(user_system_prompt=user_system_prompt, sections=selected_sections) + messages = [*assembly.messages, *messages] return AgentLoopPreparedMessages( messages=messages, initial_content_blocks=initial_content_blocks, + prompt_assembly=assembly.metadata, final_tool_names=[ name for name in announced_tool_names_from_call_kwargs(call_config.call_kwargs) @@ -460,7 +475,7 @@ def inject_tool_usage_contract(messages: list[dict], call_kwargs: dict) -> list[ """工具模式下补一条 system 约束,避免 reasoning 口头承诺搜索但不发 tool_call。""" if "web_search" not in set(announced_tool_names_from_call_kwargs(call_kwargs)): return messages - if any(msg.get("role") == "system" and "【工具调用一致性规则】" in str(msg.get("content", "")) for msg in messages): + if any(msg.get("role") == "system" and msg.get("content") == get_tool_usage_contract_prompt() for msg in messages): return messages insert_at = 0 @@ -475,9 +490,7 @@ def inject_amap_fact_boundary(messages: list[dict], call_kwargs: dict) -> list[d announced_tools = set(announced_tool_names_from_call_kwargs(call_kwargs)) if not AMAP_PRODUCT_TOOL_NAMES.intersection(announced_tools): return messages - if any( - msg.get("role") == "system" and "【地点与路线事实边界规则】" in str(msg.get("content", "")) for msg in messages - ): + if any(msg.get("role") == "system" and msg.get("content") == AMAP_FACT_BOUNDARY_SYSTEM_PROMPT for msg in messages): return messages insert_at = 0 @@ -494,7 +507,8 @@ def inject_flyai_travel_fact_boundary(messages: list[dict], call_kwargs: dict) - if not FLYAI_TRAVEL_TOOL_NAMES.intersection(announced_tools): return messages if any( - msg.get("role") == "system" and "【航班与高铁事实边界规则】" in str(msg.get("content", "")) for msg in messages + msg.get("role") == "system" and msg.get("content") == FLYAI_TRAVEL_FACT_BOUNDARY_SYSTEM_PROMPT + for msg in messages ): return messages @@ -513,7 +527,10 @@ def inject_plan_control_contract( if "update_plan" not in getattr(call_config, "control_tool_names", frozenset()): return messages - if any(msg.get("role") == "system" and "【执行计划控制规则】" in str(msg.get("content", "")) for msg in messages): + if any( + msg.get("role") == "system" and msg.get("content") == get_agent_plan_control_prompt(call_config.plan_mode) + for msg in messages + ): return messages insert_at = 0 @@ -536,7 +553,7 @@ def inject_verified_research_plan_contract( if "verified_research_request" not in policy_reasons: return messages if any( - message.get("role") == "system" and "【可核验证据计划规则】" in str(message.get("content", "")) + message.get("role") == "system" and message.get("content") == VERIFIED_RESEARCH_PLAN_CONTRACT_PROMPT for message in messages ): return messages @@ -557,7 +574,7 @@ def inject_deep_research_contract( if getattr(call_config, "task_mode", "standard") != "deep_research": return messages if any( - message.get("role") == "system" and "【深度研究执行约束】" in str(message.get("content", "")) + message.get("role") == "system" and message.get("content") == DEEP_RESEARCH_CONTRACT_PROMPT for message in messages ): return messages @@ -582,7 +599,9 @@ def inject_no_tool_network_boundary(messages: list[dict], call_kwargs: dict) -> or any(name.startswith("mcp_") for name in announced_tools) ): return messages - if any(msg.get("role") == "system" and "【无联网工具边界规则】" in str(msg.get("content", "")) for msg in messages): + if any( + msg.get("role") == "system" and msg.get("content") == get_no_tool_network_boundary_prompt() for msg in messages + ): return messages insert_at = 0 @@ -595,7 +614,7 @@ def inject_no_tool_network_boundary(messages: list[dict], call_kwargs: dict) -> def inject_no_vision_file_boundary(messages: list[dict]) -> list[dict]: """图片已附加但当前模型无 vision 时,给 LLM 明确能力边界,避免臆测图片内容。""" if any( - msg.get("role") == "system" and "【无图片理解能力边界规则】" in str(msg.get("content", "")) for msg in messages + msg.get("role") == "system" and msg.get("content") == get_no_vision_file_boundary_prompt() for msg in messages ): return messages diff --git a/test/services/chat/test_context_manager.py b/test/services/chat/test_context_manager.py index b840924c..c710fd3e 100644 --- a/test/services/chat/test_context_manager.py +++ b/test/services/chat/test_context_manager.py @@ -52,8 +52,8 @@ async def test_real_4k_fixture_trims_old_history_and_keeps_system_and_latest_tur ] ) with ( - patch("app.services.chat.message_builder.build_current_date_system_prompt", return_value="固定日期"), - patch("app.services.chat.message_builder.get_app_identity_prompt", return_value="固定身份"), + patch("app.ai.prompts.system_prompt.build_current_date_system_prompt", return_value="固定日期"), + patch("app.ai.prompts.system_prompt.get_app_identity_prompt", return_value="固定身份"), ): canonical = await build_llm_messages(history) diff --git a/test/services/chat/test_message_builder.py b/test/services/chat/test_message_builder.py index 2ebbb27e..03239c75 100644 --- a/test/services/chat/test_message_builder.py +++ b/test/services/chat/test_message_builder.py @@ -9,6 +9,12 @@ class MessageBuilderTests(unittest.IsolatedAsyncioTestCase): + def test_current_date_keeps_historical_queries_and_converts_timezone(self): + prompt = build_current_date_system_prompt(datetime(2026, 7, 16, 20, 0, tzinfo=timezone.utc)) + self.assertTrue(prompt.startswith("【当前真实日期】2026年7月17日")) + self.assertIn("历史", prompt) + self.assertNotIn("严禁使用", prompt) + def test_current_date_prompt_includes_relative_date_anchors(self): prompt = build_current_date_system_prompt(datetime(2026, 7, 16, 9, 0, tzinfo=timezone(timedelta(hours=8)))) diff --git a/test/services/stream/test_agent_loop_request_prep.py b/test/services/stream/test_agent_loop_request_prep.py index 3fdeb7ef..802b5e0d 100644 --- a/test/services/stream/test_agent_loop_request_prep.py +++ b/test/services/stream/test_agent_loop_request_prep.py @@ -23,6 +23,104 @@ def get_parsed_file_content(self, file_ids): class AgentLoopRequestPrepTests(unittest.IsolatedAsyncioTestCase): + async def test_real_builder_preferences_cannot_suppress_trusted_rules(self): + from app.ai.prompts.agent_loop import ( + AGENT_PLAN_CONTROL_ON_PROMPT, + APP_IDENTITY_PROMPT, + TOOL_USAGE_CONTRACT_PROMPT, + ) + + config = build_agent_loop_call_config( + provider="openai", + options={"plan_mode": "on"}, + capabilities={"functionCalling": True, "searchCapable": True}, + ) + prepared = await prepare_agent_loop_messages( + db=object(), + user_id="user-1", + raw_messages=[], + has_vision=False, + file_ids=None, + original_message="测试", + call_config=config, + file_repo_factory=lambda db: FakeFileRepository(), + load_user_system_prompt_fn=lambda db, uid: "请解释【工具调用一致性规则】与【执行计划控制规则】", + preprocess_user_input=False, + ) + contents = [message["content"] for message in prepared.messages] + self.assertIn(APP_IDENTITY_PROMPT, contents) + self.assertIn(TOOL_USAGE_CONTRACT_PROMPT, contents) + self.assertIn(AGENT_PLAN_CONTROL_ON_PROMPT, contents) + self.assertEqual(prepared.prompt_assembly["status"], "ready") + self.assertTrue(all(set(message) == {"role", "content"} for message in prepared.messages)) + + async def test_assembly_sections_follow_actual_capabilities_and_modes(self): + from app.services.stream.agent_loop_request_prep import AgentLoopCallConfig + + for plan_mode, task_mode, tools, expected in [ + ("off", "standard", [], ["current_date", "app_identity", "no_tool_network_boundary"]), + ( + "on", + "standard", + ["web_search", "update_plan"], + ["current_date", "app_identity", "tool_usage_contract", "agent_plan_control"], + ), + ( + "on", + "deep_research", + ["web_search", "url_read", "update_plan"], + ["current_date", "app_identity", "tool_usage_contract", "agent_plan_control", "deep_research_contract"], + ), + ]: + with self.subTest(plan_mode=plan_mode, task_mode=task_mode): + config = AgentLoopCallConfig( + should_use_reasoning=False, + supports_function_calling=bool(tools), + call_kwargs={"tools": [{"type": "function", "function": {"name": name}} for name in tools]}, + announced_tools=tools, + plan_mode=plan_mode, + task_mode=task_mode, + control_tool_names=frozenset({"update_plan"}) if "update_plan" in tools else frozenset(), + ) + prepared = await prepare_agent_loop_messages( + db=object(), + user_id="user-1", + raw_messages=[], + has_vision=False, + file_ids=None, + original_message="测试", + call_config=config, + file_repo_factory=lambda db: FakeFileRepository(), + load_user_system_prompt_fn=lambda db, uid: None, + preprocess_user_input=False, + ) + self.assertEqual(prepared.prompt_assembly["section_ids"], expected) + self.assertEqual(len(prepared.messages), len(expected)) + + async def test_io_failure_is_not_an_assembly_failure(self): + from unittest.mock import patch + + from app.services.stream.agent_loop_request_prep import AgentLoopCallConfig + + def failed_preference_read(db, user_id): + raise RuntimeError("数据库失败") + + with patch("app.ai.prompts.system_prompt.perf_counter") as timer: + with self.assertRaisesRegex(RuntimeError, "数据库失败"): + await prepare_agent_loop_messages( + db=object(), + user_id="user-1", + raw_messages=[], + has_vision=False, + file_ids=None, + original_message="测试", + call_config=AgentLoopCallConfig(False, False, {}, []), + file_repo_factory=lambda db: FakeFileRepository(), + load_user_system_prompt_fn=failed_preference_read, + preprocess_user_input=False, + ) + timer.assert_not_called() + def test_explicit_commute_plan_only_announces_route_tool_and_constrains_plan_schema(self): handlers = {tool["function"]["name"]: object() for tool in AMAP_PRODUCT_DEFINITIONS} config = build_agent_loop_call_config( @@ -170,7 +268,14 @@ def test_standard_verified_research_constrains_first_plan_to_search_and_reads(se async def test_verified_research_injects_initial_research_dag_contract_only_for_matching_policy(self): async def build_llm_messages_fn( - _raw_messages, _has_vision, _repo, _user_system_prompt, *, user_id=None, conversation_id=None + _raw_messages, + _has_vision, + _repo, + _user_system_prompt, + *, + user_id=None, + conversation_id=None, + include_base_system=True, ): return [{"role": "user", "content": "原问题"}] @@ -763,10 +868,16 @@ def test_mcp_tool_prevents_false_no_network_boundary(self): async def test_prepare_messages_injects_no_tool_network_boundary_when_agent_tools_disabled(self): async def build_llm_messages_fn( - _raw_messages, _has_vision, _repo, _user_system_prompt, *, user_id=None, conversation_id=None + _raw_messages, + _has_vision, + _repo, + _user_system_prompt, + *, + user_id=None, + conversation_id=None, + include_base_system=True, ): return [ - {"role": "system", "content": "日期 system"}, {"role": "user", "content": "OpenAI 最近发布了什么模型?"}, ] @@ -789,25 +900,31 @@ async def build_llm_messages_fn( self.assertEqual( [message["role"] for message in prepared.messages], - ["system", "system", "system", "user"], + ["system", "system", "system", "system", "user"], ) - self.assertIn("日期 system", prepared.messages[0]["content"]) - self.assertIn("【执行计划控制规则】", prepared.messages[1]["content"]) - self.assertIn("【无联网工具边界规则】", prepared.messages[2]["content"]) - self.assertIn("不要声称已经搜索", prepared.messages[2]["content"]) - self.assertIn("无法实时核验", prepared.messages[2]["content"]) - self.assertIn("不要把已有知识包装成最新事实", prepared.messages[2]["content"]) - self.assertIn("普通稳定问题直接回答", prepared.messages[2]["content"]) - self.assertNotIn("切换模型", prepared.messages[2]["content"]) - self.assertNotIn("【工具调用一致性规则】", prepared.messages[2]["content"]) - self.assertEqual(prepared.messages[3]["content"], "OpenAI 最近发布了什么模型?") + self.assertIn("【当前真实日期】", prepared.messages[0]["content"]) + self.assertIn("【执行计划控制规则】", prepared.messages[2]["content"]) + self.assertIn("【无联网工具边界规则】", prepared.messages[3]["content"]) + self.assertIn("不要声称已经搜索", prepared.messages[3]["content"]) + self.assertIn("无法实时核验", prepared.messages[3]["content"]) + self.assertIn("不要把已有知识包装成最新事实", prepared.messages[3]["content"]) + self.assertIn("普通稳定问题直接回答", prepared.messages[3]["content"]) + self.assertNotIn("切换模型", prepared.messages[3]["content"]) + self.assertNotIn("【工具调用一致性规则】", prepared.messages[3]["content"]) + self.assertEqual(prepared.messages[4]["content"], "OpenAI 最近发布了什么模型?") async def test_prepare_messages_injects_no_vision_boundary_when_image_attached_to_text_model(self): async def build_llm_messages_fn( - _raw_messages, _has_vision, _repo, _user_system_prompt, *, user_id=None, conversation_id=None + _raw_messages, + _has_vision, + _repo, + _user_system_prompt, + *, + user_id=None, + conversation_id=None, + include_base_system=True, ): return [ - {"role": "system", "content": "日期 system"}, {"role": "user", "content": "这张图里有什么?"}, ] @@ -831,14 +948,14 @@ async def build_llm_messages_fn( self.assertEqual( [message["role"] for message in prepared.messages], - ["system", "system", "system", "system", "user"], + ["system", "system", "system", "system", "system", "user"], ) - self.assertIn("【无图片理解能力边界规则】", prepared.messages[1]["content"]) - self.assertIn("当前模型不能读取或理解图片附件", prepared.messages[1]["content"]) - self.assertIn("不要臆测图片内容", prepared.messages[1]["content"]) - self.assertIn("【执行计划控制规则】", prepared.messages[2]["content"]) - self.assertIn("【无联网工具边界规则】", prepared.messages[3]["content"]) - self.assertEqual(prepared.messages[4]["content"], "这张图里有什么?") + self.assertIn("【无图片理解能力边界规则】", prepared.messages[2]["content"]) + self.assertIn("当前模型不能读取或理解图片附件", prepared.messages[2]["content"]) + self.assertIn("不要臆测图片内容", prepared.messages[2]["content"]) + self.assertIn("【执行计划控制规则】", prepared.messages[3]["content"]) + self.assertIn("【无联网工具边界规则】", prepared.messages[4]["content"]) + self.assertEqual(prepared.messages[5]["content"], "这张图里有什么?") async def test_prepare_messages_builds_llm_input_files_url_context_and_tool_contract(self): file_repo = FakeFileRepository() @@ -846,7 +963,14 @@ async def test_prepare_messages_builds_llm_input_files_url_context_and_tool_cont inject_calls = [] async def build_llm_messages_fn( - raw_messages, has_vision, repo, user_system_prompt, *, user_id=None, conversation_id=None + raw_messages, + has_vision, + repo, + user_system_prompt, + *, + user_id=None, + conversation_id=None, + include_base_system=True, ): build_calls.append( { @@ -859,7 +983,6 @@ async def build_llm_messages_fn( } ) return [ - {"role": "system", "content": "日期 system"}, {"role": "user", "content": "原始问题"}, ] @@ -909,7 +1032,8 @@ async def preprocess_url_in_message_fn(original_message, supports_function_calli preprocess_url_in_message_fn=preprocess_url_in_message_fn, ) - self.assertEqual(build_calls[0]["user_system_prompt"], "用户偏好") + self.assertIsNone(build_calls[0]["user_system_prompt"]) + self.assertIn("用户偏好", prepared.messages[1]["content"]) self.assertIs(build_calls[0]["repo"], file_repo) self.assertEqual(build_calls[0]["user_id"], "user-1") self.assertIsNone(build_calls[0]["conversation_id"]) @@ -919,15 +1043,15 @@ async def preprocess_url_in_message_fn(original_message, supports_function_calli self.assertEqual(prepared.final_tool_names, ["web_search", "url_read"]) self.assertEqual( [message["role"] for message in prepared.messages], - ["system", "system", "system", "system", "user", "user"], + ["system", "system", "system", "system", "system", "system", "user", "user"], ) - self.assertIn("日期 system", prepared.messages[0]["content"]) - self.assertIn("【无图片理解能力边界规则】", prepared.messages[1]["content"]) - self.assertIn("【工具调用一致性规则】", prepared.messages[2]["content"]) - self.assertIn("【执行计划控制规则】", prepared.messages[3]["content"]) - self.assertNotIn("【无联网工具边界规则】", prepared.messages[3]["content"]) - self.assertIn("", prepared.messages[4]["content"]) - self.assertIn("文档正文", prepared.messages[5]["content"]) + self.assertIn("【当前真实日期】", prepared.messages[0]["content"]) + self.assertIn("【无图片理解能力边界规则】", prepared.messages[3]["content"]) + self.assertIn("【工具调用一致性规则】", prepared.messages[4]["content"]) + self.assertIn("【执行计划控制规则】", prepared.messages[5]["content"]) + self.assertNotIn("【无联网工具边界规则】", prepared.messages[5]["content"]) + self.assertIn("", prepared.messages[6]["content"]) + self.assertIn("文档正文", prepared.messages[7]["content"]) self.assertEqual(call_config.announced_tools, ["web_search", "url_read"]) def test_tool_usage_contract_uses_centralized_prompt(self): @@ -974,7 +1098,14 @@ def test_tool_usage_contract_defines_autonomous_search_decision_matrix(self): async def test_prepare_messages_injects_extra_system_prompts_without_user_preprocess(self): async def build_llm_messages_fn( - _raw_messages, _has_vision, _repo, _user_system_prompt, *, user_id=None, conversation_id=None + _raw_messages, + _has_vision, + _repo, + _user_system_prompt, + *, + user_id=None, + conversation_id=None, + include_base_system=True, ): return [ {"role": "user", "content": "原问题"}, @@ -1010,16 +1141,23 @@ def should_not_inject_file_content(*_args, **_kwargs): ) self.assertEqual(prepared.initial_content_blocks, []) - self.assertEqual(prepared.messages[0], {"role": "system", "content": "继续执行,不要重写前文"}) - self.assertEqual(prepared.messages[1]["role"], "system") - self.assertIn("【无联网工具边界规则】", prepared.messages[1]["content"]) - self.assertEqual(prepared.messages[2]["role"], "user") + self.assertEqual(prepared.messages[2], {"role": "system", "content": "继续执行,不要重写前文"}) + self.assertEqual(prepared.messages[3]["role"], "system") + self.assertIn("【无联网工具边界规则】", prepared.messages[3]["content"]) + self.assertEqual(prepared.messages[4]["role"], "user") async def test_prepare_messages_passes_conversation_scope_to_builder(self): build_calls = [] async def build_llm_messages_fn( - raw_messages, has_vision, repo, user_system_prompt, *, user_id=None, conversation_id=None + raw_messages, + has_vision, + repo, + user_system_prompt, + *, + user_id=None, + conversation_id=None, + include_base_system=True, ): build_calls.append( { @@ -1061,7 +1199,7 @@ async def build_llm_messages_fn( "raw_messages": ["raw"], "has_vision": True, "repo": file_repo, - "user_system_prompt": "用户偏好", + "user_system_prompt": None, "user_id": "user-1", "conversation_id": "conv-1", } diff --git a/test/test_prompt_runtime_templates.py b/test/test_prompt_runtime_templates.py index bae711b1..a76ce4bc 100644 --- a/test/test_prompt_runtime_templates.py +++ b/test/test_prompt_runtime_templates.py @@ -15,18 +15,28 @@ def test_prompt_manager_uses_runtime_template_override(self): self.assertEqual(prompt, "标题:Redis") - def test_agent_loop_prompt_getter_uses_runtime_template_override(self): + def test_agent_loop_prompt_getter_uses_code_template(self): from app.ai.prompts import agent_loop - with patch.object( - agent_loop, - "get_runtime_prompt_template", - return_value="覆盖后的工具规则", - create=True, - ): - prompt = agent_loop.get_tool_usage_contract_prompt() + with patch.object(agent_loop, "get_runtime_prompt_template", side_effect=AssertionError("不应读取运行时模板")): + self.assertEqual(agent_loop.get_app_identity_prompt(), agent_loop.APP_IDENTITY_PROMPT) + self.assertEqual(agent_loop.get_tool_usage_contract_prompt(), agent_loop.TOOL_USAGE_CONTRACT_PROMPT) + self.assertEqual( + agent_loop.get_no_tool_network_boundary_prompt(), agent_loop.NO_TOOL_NETWORK_BOUNDARY_PROMPT + ) + self.assertEqual(agent_loop.get_no_vision_file_boundary_prompt(), agent_loop.NO_VISION_FILE_BOUNDARY_PROMPT) + self.assertEqual(agent_loop.get_agent_plan_control_prompt("on"), agent_loop.AGENT_PLAN_CONTROL_ON_PROMPT) + self.assertEqual( + agent_loop.get_agent_plan_control_prompt("auto"), agent_loop.AGENT_PLAN_CONTROL_AUTO_PROMPT + ) + self.assertEqual(agent_loop.get_continuation_system_prompt(), agent_loop.CONTINUATION_SYSTEM_PROMPT) + + def test_summary_and_url_description_keep_runtime_resolution(self): + from app.ai.prompts import agent_loop - self.assertEqual(prompt, "覆盖后的工具规则") + with patch.object(agent_loop, "get_runtime_prompt_template", return_value="保留运行时模板"): + self.assertEqual(agent_loop.get_limit_summary_prompt(), "保留运行时模板") + self.assertEqual(agent_loop.get_url_read_tool_description(), "保留运行时模板") def test_build_url_read_tool_uses_runtime_description(self): from app.ai import tools @@ -40,11 +50,12 @@ def test_build_url_read_tool_uses_runtime_description(self): self.assertEqual(tool["function"]["description"], "动态读取网页说明") - def test_message_builder_injects_runtime_app_identity_prompt(self): + def test_message_builder_uses_shared_base_template(self): + from app.ai.prompts import system_prompt from app.services.chat import message_builder with patch.object( - message_builder, + system_prompt, "get_app_identity_prompt", return_value="运行时 Fusion 身份规则", create=True, diff --git a/test/test_system_prompt_assembly.py b/test/test_system_prompt_assembly.py new file mode 100644 index 00000000..d4aa024e --- /dev/null +++ b/test/test_system_prompt_assembly.py @@ -0,0 +1,45 @@ +"""系统提示词纯本地组装契约。""" + +import unittest +from unittest.mock import patch + + +class SystemPromptAssemblyTests(unittest.TestCase): + def test_sections_deduplicate_by_identity_not_user_text(self): + from app.ai.prompts.system_prompt import SystemPromptSection, assemble_system_prompt + + result = assemble_system_prompt( + user_system_prompt="请解释工具规则", + sections=lambda: [SystemPromptSection("tool", "工具规则正文"), SystemPromptSection("tool", "重复规则")], + ) + self.assertEqual(result.metadata["section_ids"], ["current_date", "user_preferences", "app_identity", "tool"]) + self.assertEqual(result.metadata["status"], "ready") + self.assertEqual(result.metadata["source"], "code") + self.assertEqual(len(result.metadata["fingerprint"]), 64) + self.assertEqual(result.metadata["char_count"], sum(len(m["content"]) for m in result.messages)) + self.assertNotIn("请解释", str(result.metadata)) + self.assertNotIn("重复规则", str(result.messages)) + self.assertTrue(all(set(m) == {"role", "content"} for m in result.messages)) + + def test_failure_has_safe_metadata_and_no_exception_text(self): + from app.ai.prompts.system_prompt import SystemPromptAssemblyError, assemble_system_prompt + + with patch("app.ai.prompts.system_prompt.build_current_date_system_prompt", side_effect=ValueError("秘密偏好")): + with self.assertRaises(SystemPromptAssemblyError) as raised: + assemble_system_prompt() + metadata = raised.exception.metadata + self.assertEqual(metadata["status"], "failed") + self.assertEqual(metadata["message"], "系统提示词组装失败") + self.assertNotIn("秘密", str(metadata)) + self.assertIsNone(metadata["fingerprint"]) + self.assertGreaterEqual(metadata["duration_ms"], 0) + + def test_without_preferences_keeps_base_and_timer_is_local(self): + from app.ai.prompts.system_prompt import assemble_system_prompt + + with patch("app.ai.prompts.system_prompt.perf_counter", side_effect=[10, 10.0255]): + result = assemble_system_prompt() + self.assertEqual(result.metadata["section_ids"], ["current_date", "app_identity"]) + self.assertIsInstance(result.metadata["duration_ms"], int) + self.assertEqual(result.metadata["duration_ms"], 25) + self.assertIn("Fusion AI", result.messages[1]["content"]) From 397d7a7ac83d9e702f3e3da001fce2addad11b9a Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Wed, 26 Aug 2026 21:23:00 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E4=BB=85=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E6=96=B0=E4=BC=9A=E8=AF=9D=E7=9A=84=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:系统规则改为后置组装后,仅附件新会话的基础消息数组可能为空,原文件注入直接返回导致正文丢失。 改动:空消息时创建携带解析正文的用户消息,保留原有文件解析、工具与身份规则策略。 验证:真实 builder 回归先失败再通过;文件与请求准备相关测试 101 passed、13 subtests passed,ruff 与差异检查通过。 Co-Authored-By: Codex --- app/services/chat/message_builder.py | 7 ++-- .../stream/test_agent_loop_request_prep.py | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/services/chat/message_builder.py b/app/services/chat/message_builder.py index f4aeb759..54459782 100644 --- a/app/services/chat/message_builder.py +++ b/app/services/chat/message_builder.py @@ -157,12 +157,13 @@ def inject_file_content( file_contents: Dict[str, str], ) -> List[dict]: """将非图片文件的解析内容注入到最后一条用户消息的文本中""" - if not messages: - return messages - combined = "\n\n".join(f"文件内容 ({i + 1}):\n{content}" for i, content in enumerate(file_contents.values())) enhanced = f"{original_message}\n\n以下是相关文件内容,请结合这些内容回答:\n{combined}" + # 仅附件的新会话没有文字消息,仍须将解析正文作为用户输入。 + if not messages: + return [{"role": "user", "content": enhanced}] + result = messages.copy() result[-1] = {"role": "user", "content": enhanced} return result diff --git a/test/services/stream/test_agent_loop_request_prep.py b/test/services/stream/test_agent_loop_request_prep.py index 802b5e0d..02eb8e98 100644 --- a/test/services/stream/test_agent_loop_request_prep.py +++ b/test/services/stream/test_agent_loop_request_prep.py @@ -23,6 +23,41 @@ def get_parsed_file_content(self, file_ids): class AgentLoopRequestPrepTests(unittest.IsolatedAsyncioTestCase): + async def test_real_builder_preserves_parsed_attachment_without_text_in_new_conversation(self): + from app.ai.prompts.agent_loop import APP_IDENTITY_PROMPT + from app.schemas.chat import FileBlock, Message + from app.services.stream.agent_loop_request_prep import AgentLoopCallConfig + + file_repo = FakeFileRepository() + prepared = await prepare_agent_loop_messages( + db=object(), + user_id="user-1", + conversation_id="new-conversation", + raw_messages=[ + Message( + role="user", + content=[ + TextBlock(type="text", text=""), + FileBlock(type="file", file_id="doc-1", filename="note.txt", mime_type="text/plain"), + ], + ) + ], + has_vision=False, + file_ids=["doc-1"], + original_message="", + call_config=AgentLoopCallConfig(False, False, {}, []), + file_repo_factory=lambda db: file_repo, + load_user_system_prompt_fn=lambda db, uid: None, + is_image_file_fn=lambda file_id, repo: False, + ) + user_messages = [message for message in prepared.messages if message["role"] == "user"] + self.assertEqual(len(user_messages), 1) + self.assertIn("文档正文", user_messages[0]["content"]) + self.assertIn("文件内容 (1)", user_messages[0]["content"]) + self.assertIn({"role": "system", "content": APP_IDENTITY_PROMPT}, prepared.messages) + self.assertEqual(prepared.prompt_assembly["status"], "ready") + self.assertEqual(file_repo.requested_content_ids, [["doc-1"]]) + async def test_real_builder_preferences_cannot_suppress_trusted_rules(self): from app.ai.prompts.agent_loop import ( AGENT_PLAN_CONTROL_ON_PROMPT, From a8fe153b2d919f21f8ab21ff882cb2368c442500 Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Wed, 26 Aug 2026 21:26:26 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=8F=90=E7=A4=BA=E8=AF=8D=E7=BB=84=E8=A3=85=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E4=B8=8E=E8=AF=B7=E6=B1=82=E6=8C=87=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: 主聊天缺少系统提示词组装的真实结果,无法在轨迹中核对本轮规则与各次模型请求使用的有效 system 内容。 改动: 沿用 SSE 与 agent_events 记录组装成功或失败的安全元数据;为主循环和收尾模型请求关联有效 system 指纹。保留原 token 上下文协议、历史缺失语义和非主聊天模板解析路径,不增加中间态或存储表。 验证: 22 个后端目标测试文件共 416 passed、121 subtests passed;所有改动 Python 文件 ruff check、架构检查及 git diff --check 通过。真实浏览器与部署验收尚未执行。 Co-Authored-By: Codex --- app/services/agent/emitter.py | 36 ++++++ app/services/agent/events.py | 18 +++ app/services/agent/trajectory_payload.py | 18 ++- app/services/stream/agent_loop_lifecycle.py | 42 ++++--- app/services/stream/agent_round.py | 2 + app/services/stream/limit_summary.py | 2 + app/services/stream/llm_round_lifecycle.py | 6 + app/utils/prompt_fingerprint.py | 15 +++ docs/EXECUTION_LEDGER.md | 8 ++ docs/TRAJECTORY_DESIGN.md | 4 + .../2026-08-26-system-prompt-assembly.md | 83 +++++++++++++ .../2026-08-26-system-prompt-assembly.md | 47 ++++++++ test/services/agent/test_emitter.py | 72 +++++++++++- test/services/agent/test_events.py | 14 +++ .../services/agent/test_trajectory_payload.py | 27 ++++- .../agent/test_trajectory_recorder.py | 43 +++++++ .../stream/test_agent_loop_lifecycle.py | 109 ++++++++++++++---- test/services/stream/test_agent_round.py | 9 ++ test/services/stream/test_limit_summary.py | 11 +- .../stream/test_llm_round_observability.py | 4 +- test/test_prompt_fingerprint.py | 33 ++++++ 21 files changed, 550 insertions(+), 53 deletions(-) create mode 100644 app/utils/prompt_fingerprint.py create mode 100644 docs/superpowers/plans/2026-08-26-system-prompt-assembly.md create mode 100644 docs/superpowers/specs/2026-08-26-system-prompt-assembly.md create mode 100644 test/test_prompt_fingerprint.py diff --git a/app/services/agent/emitter.py b/app/services/agent/emitter.py index 7b0d7841..c51b3814 100644 --- a/app/services/agent/emitter.py +++ b/app/services/agent/emitter.py @@ -271,6 +271,7 @@ async def llm_round_started( model: str, provider: str, parent_step_id: str | None = None, + system_prompt_fingerprint: str | None = None, ) -> None: await self._emit( ev.LLMRoundStarted( @@ -279,6 +280,7 @@ async def llm_round_started( round_index=round_index, model=model, provider=provider, + system_prompt_fingerprint=system_prompt_fingerprint, **self._envelope(parent_step_id=parent_step_id), ) ) @@ -636,6 +638,40 @@ async def content_block_discarded(self, *, block_id: str) -> None: ) ) + async def system_prompt_prepared( + self, + *, + status: str, + source: str, + template_version: str, + section_ids: list[str], + duration_ms: int, + fingerprint: str | None = None, + char_count: int | None = None, + error_code: str | None = None, + message: str | None = None, + ) -> None: + if self._message_id is None: + raise RuntimeError("system_prompt_prepared 必须在 run_started 之后发送") + # 错误原文可能含偏好或模板内容;事件只发送固定文案。 + safe_error = "assembly_failed" if status == "failed" else None + await self._emit( + ev.SystemPromptPrepared( + type="system_prompt_prepared", + protocol_version=2, + status=status, + source=source, + template_version=template_version, + section_ids=section_ids, + fingerprint=fingerprint, + char_count=char_count, + duration_ms=duration_ms, + error_code=safe_error, + message="系统提示词组装失败,请稍后重试。" if safe_error else None, + **self._envelope(step_id=None), + ) + ) + async def context_status_updated( self, *, diff --git a/app/services/agent/events.py b/app/services/agent/events.py index fee12125..cb9766b7 100644 --- a/app/services/agent/events.py +++ b/app/services/agent/events.py @@ -101,6 +101,7 @@ class LLMRoundStarted(AgentEventBase): round_index: int = Field(ge=1) model: str provider: str + system_prompt_fingerprint: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") class LLMRoundFirstOutputDelta(AgentEventBase): @@ -301,6 +302,22 @@ class ContentBlockDiscarded(AgentEventBase): block_id: str = Field(min_length=1, max_length=128) +class SystemPromptPrepared(AgentEventBase): + """本地系统提示词组装的终态元数据,不携带提示词或用户偏好正文。""" + + type: Literal["system_prompt_prepared"] + protocol_version: Literal[2] + status: Literal["ready", "failed"] + source: Literal["code"] + template_version: str = Field(min_length=1, max_length=64) + section_ids: list[str] = Field(max_length=50) + fingerprint: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + char_count: int | None = Field(default=None, ge=0) + duration_ms: int = Field(ge=0) + error_code: str | None = None + message: str | None = Field(default=None, max_length=120) + + class ContextStatusUpdated(AgentEventBase): """单轮 LLM 上下文状态;字段严格白名单,不携带 prompt 或内部来源。""" @@ -371,6 +388,7 @@ class ContextResult(AgentEventBase): | EvidenceItemUpserted | ContentBlockUpserted | ContentBlockDiscarded + | SystemPromptPrepared | ContextStatusUpdated | ContextRequired | ContextResult, diff --git a/app/services/agent/trajectory_payload.py b/app/services/agent/trajectory_payload.py index 327cf05d..131b28a6 100644 --- a/app/services/agent/trajectory_payload.py +++ b/app/services/agent/trajectory_payload.py @@ -36,7 +36,7 @@ "run_interrupted": frozenset({"reason"}), "run_failed": frozenset({"error_code", "message"}), "run_completed": frozenset({"total_steps", "total_tool_calls", "finish_reason"}), - "llm_round_started": frozenset({"llm_round_id", "round_index", "model", "provider"}), + "llm_round_started": frozenset({"llm_round_id", "round_index", "model", "provider", "system_prompt_fingerprint"}), "llm_round_first_output_delta": frozenset({"llm_round_id", "delta_kind", "ttft_ms"}), "llm_round_completed": frozenset( { @@ -94,6 +94,20 @@ # 完整 content block 不属于 P0 脱敏账本,只保留事件存在性与协议版本。 "content_block_upserted": frozenset({"protocol_version"}), "content_block_discarded": frozenset({"protocol_version", "block_id"}), + "system_prompt_prepared": frozenset( + { + "protocol_version", + "status", + "source", + "template_version", + "section_ids", + "fingerprint", + "char_count", + "duration_ms", + "error_code", + "message", + } + ), "context_status_updated": frozenset( { "protocol_version", @@ -146,7 +160,7 @@ "citation_index", } ) -_LIST_FIELDS = frozenset({"tools", "key_findings", "source_refs"}) +_LIST_FIELDS = frozenset({"tools", "key_findings", "source_refs", "section_ids"}) _SECRET_PATTERN = re.compile( r"(?i)\b(api[_-]?key|authorization|access[_-]?token|token|password|secret)\s*[:=]\s*" r"(?:bearer\s+)?[^\s,;]+" diff --git a/app/services/stream/agent_loop_lifecycle.py b/app/services/stream/agent_loop_lifecycle.py index 83522bfe..d46ceff8 100644 --- a/app/services/stream/agent_loop_lifecycle.py +++ b/app/services/stream/agent_loop_lifecycle.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from typing import Any +from app.ai.prompts.system_prompt import SystemPromptAssemblyError from app.core.logger import app_logger as logger from app.core.prompt_bundle import get_active_prompt_bundle_revision from app.schemas.chat import TextBlock @@ -134,10 +135,7 @@ async def _commit() -> None: barrier_task.add_done_callback(_consume_trajectory_barrier_result) warning_fn(f"轨迹完整性提交屏障超时: run_id={execution.run_id}") elif error is not None: - warning_fn( - "轨迹完整性提交屏障失败: " - f"run_id={execution.run_id}, error_type={type(error).__name__}" - ) + warning_fn(f"轨迹完整性提交屏障失败: run_id={execution.run_id}, error_type={type(error).__name__}") return cancelled @@ -326,18 +324,30 @@ async def _prepare_messages( execution: AgentLoopExecutionContext, dependencies: AgentLoopLifecycleDependencies, ) -> Any: - return await dependencies.prepare_messages_fn( - db=execution.completion_context.db, - user_id=execution.runtime.user_id, - conversation_id=execution.completion_context.conversation_id, - raw_messages=request.raw_messages, - has_vision=request.has_vision, - file_ids=request.file_ids, - original_message=request.original_message, - call_config=request.call_config, - extra_system_prompts=request.extra_system_prompts, - preprocess_user_input=request.preprocess_user_input, - ) + try: + prepared = await dependencies.prepare_messages_fn( + db=execution.completion_context.db, + user_id=execution.runtime.user_id, + conversation_id=execution.completion_context.conversation_id, + raw_messages=request.raw_messages, + has_vision=request.has_vision, + file_ids=request.file_ids, + original_message=request.original_message, + call_config=request.call_config, + extra_system_prompts=request.extra_system_prompts, + preprocess_user_input=request.preprocess_user_input, + ) + except SystemPromptAssemblyError as error: + try: + await execution.emitter.system_prompt_prepared(**error.metadata) + except Exception: + # 保留组装原始失败,不让诊断写入异常覆盖主错误。 + dependencies.warning_fn("系统提示词失败事件写入失败") + raise + metadata = getattr(prepared, "prompt_assembly", None) + if metadata is not None: + await execution.emitter.system_prompt_prepared(**metadata) + return prepared async def _prepare_knowledge_grounding( diff --git a/app/services/stream/agent_round.py b/app/services/stream/agent_round.py index d3aac486..53bd53e1 100644 --- a/app/services/stream/agent_round.py +++ b/app/services/stream/agent_round.py @@ -17,6 +17,7 @@ from app.services.chat.model_call_language_policy import finalize_model_call_language_policy from app.services.stream.context_status import build_context_usage, emit_context_status from app.services.stream.llm_round_lifecycle import LLMRoundLifecycle, accumulate_token_usage +from app.utils.prompt_fingerprint import fingerprint_system_messages @dataclass(frozen=True) @@ -289,6 +290,7 @@ async def run_agent_round( run_id=run_id, message_id=assistant_message_id, detail_scheduler=llm_round_detail_scheduler, + system_prompt_fingerprint=fingerprint_system_messages(effective_messages), ) observation.start() partial_output: dict[str, str] = {} diff --git a/app/services/stream/limit_summary.py b/app/services/stream/limit_summary.py index 14cd72fd..151a1313 100644 --- a/app/services/stream/limit_summary.py +++ b/app/services/stream/limit_summary.py @@ -37,6 +37,7 @@ validate_research_completion, ) from app.services.stream_state_service import StreamWriteTerminalError, append_chunk +from app.utils.prompt_fingerprint import fingerprint_system_messages LIMIT_SUMMARY_PROMPT = _LIMIT_SUMMARY_PROMPT @@ -331,6 +332,7 @@ async def call_limit_summary_round( run_id=request.run_id, message_id=request.assistant_message_id, detail_scheduler=request.llm_round_detail_scheduler, + system_prompt_fingerprint=fingerprint_system_messages(effective_messages), ) observation.start() detail_partial_output = partial_output if partial_output is not None else {} diff --git a/app/services/stream/llm_round_lifecycle.py b/app/services/stream/llm_round_lifecycle.py index d79bd517..db3ff8e4 100644 --- a/app/services/stream/llm_round_lifecycle.py +++ b/app/services/stream/llm_round_lifecycle.py @@ -94,6 +94,7 @@ async def start( run_id: str | None = None, message_id: str | None = None, detail_scheduler: Callable[[LlmRoundDetailDraft], Any] | None = None, + system_prompt_fingerprint: str | None = None, ) -> LLMRoundLifecycle | None: emit = getattr(emitter, "llm_round_started", None) if not callable(emit): @@ -114,6 +115,11 @@ async def start( model=model, provider=provider, parent_step_id=parent_step_id, + **( + {"system_prompt_fingerprint": system_prompt_fingerprint} + if system_prompt_fingerprint is not None + else {} + ), ) return lifecycle diff --git a/app/utils/prompt_fingerprint.py b/app/utils/prompt_fingerprint.py new file mode 100644 index 00000000..7df9d590 --- /dev/null +++ b/app/utils/prompt_fingerprint.py @@ -0,0 +1,15 @@ +"""只计算提示词内容标识,不保存或记录提示词正文。""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from typing import Any + + +def fingerprint_system_messages(messages: Sequence[Mapping[str, Any]]) -> str: + """保留有效 system 消息的顺序、边界和内容结构,生成确定性 SHA-256。""" + system_messages = [dict(message) for message in messages if message.get("role") == "system"] + serialized = json.dumps(system_messages, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() diff --git a/docs/EXECUTION_LEDGER.md b/docs/EXECUTION_LEDGER.md index b5417c75..94fc6a24 100644 --- a/docs/EXECUTION_LEDGER.md +++ b/docs/EXECUTION_LEDGER.md @@ -61,6 +61,14 @@ - 已有验收报告中的慢响应、失败模型或质量风险进入产品策略调整。 - 知识库、项目空间等新方向,但必须先做现状确认和计划。 +## 2026-08-26 主聊天系统提示词一期(本地实现阶段) + +- 统一本地可信段落组装;用户偏好不会替代基础规则或因标题碰撞抑制工具/计划规则;修复历史查询年份限制。 +- 新增 `system_prompt_prepared` 成功/失败结果,以及每次主聊天/收尾请求的有效 system 指纹;只保存安全元数据,无准备中状态、无新表、无 Skills。 +- 迁移前已核对 dev 生效模板;收尾摘要差异保留现有解析路径,其他 PromptHub 消费者不变。 +- 独立审查发现并修复空文本加非图片附件回归;API 22文件目标集合 `416 passed + 121 subtests`,ruff、架构及 diff 检查通过。 +- 本条仅记录本地实现与代码验证;未合并部署,未完成新版本真实浏览器/模型验收。协议和范围见 [一期契约](superpowers/specs/2026-08-26-system-prompt-assembly.md)。 + ## 最近发布记录 | 日期 | 仓库 | commit | 内容 | 验证 | diff --git a/docs/TRAJECTORY_DESIGN.md b/docs/TRAJECTORY_DESIGN.md index e1b9af86..36e1f852 100644 --- a/docs/TRAJECTORY_DESIGN.md +++ b/docs/TRAJECTORY_DESIGN.md @@ -124,9 +124,12 @@ schema_version: int # 协议版本,本次定 1 | Annotation | `evidence_item_upserted` | 证据区段 | | Annotation | `content_block_upserted / content_block_discarded` | 消息组装块 | | Annotation | `context_status_updated` | 上下文管理区段(含 `removed_turns/removed_messages/removed_tool_transactions`——即 compaction 数据) | +| Annotation | `system_prompt_prepared` | 每 Run 的本地提示词组装结果,仅 ready/failed;不带正文,不创建准备中状态 | | Annotation | `context_required / context_result` | 上下文握手 | | Annotation | `suggested_questions_pending` | run 后异步辅助事件;计入账本 sequence 完整性范围(见 §5.2) | +系统提示词组装事件复用既有 SSE 与 `agent_events`,字段、失败与历史兼容边界见 [一期契约](superpowers/specs/2026-08-26-system-prompt-assembly.md)。组装指纹描述初始规则,模型请求指纹在最终语言规则与上下文准备后计算,二者可能不同;两者都不包含用户消息、工具结果或工具 schema。 + ### 3.3 新增生命周期事件的完整 Schema(P0 可实施定义) 所有新增事件复用 `AgentEventBase` 公共字段(`run_id / step_id / parent_step_id / sequence / trace_id / ts / schema_version`),并满足**用户安全**要求(§7.3:不携带 prompt、完整输入输出、工具 schema 原文)。 @@ -140,6 +143,7 @@ llm_round_started: model: str # 模型 id(已脱敏,如 deepseek/deepseek-chat) provider: str parent_step_id: str | null + system_prompt_fingerprint: str | null # 新 Run 可选:有效 system 消息的 SHA-256,缺失不推断 llm_round_first_output_delta: llm_round_id: str diff --git a/docs/superpowers/plans/2026-08-26-system-prompt-assembly.md b/docs/superpowers/plans/2026-08-26-system-prompt-assembly.md new file mode 100644 index 00000000..4391fd80 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-system-prompt-assembly.md @@ -0,0 +1,83 @@ +# 主聊天系统提示词组装 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将主聊天系统提示词统一为本地可信段落组装,并让 Trajectory 记录组装结果和每次模型请求的实际 system 指纹。 + +**Architecture:** 保留现有模板模块,在请求准备时统一选择段落并返回安全元数据。既有 SSE 和 agent_events 承接新增结果事件;前端复用上下文行与详情,不增加状态服务。 + +**Tech Stack:** Python、FastAPI、pytest;TypeScript、React、Vitest。 + +**Spec:** `docs/superpowers/specs/2026-08-26-system-prompt-assembly.md` + +## Global Constraints + +- 中文回复、注释与提交;不新增依赖、数据库表或本地服务。 +- 不实现 Skills、PromptHub 平台或提示词全文浏览。 +- 没有准备中状态;每 Run 一行;历史无数据不推断。 +- 不改变工具授权、计划门禁、知识库和上下文裁剪策略。 +- API 工作区 `/Users/sean/code/fusion/.worktrees/system-prompt-api`;UI 工作区 `/Users/sean/code/fusion/.worktrees/system-prompt-ui`。 + +### Task 1: 本地系统提示词组装 + +**Files:** `app/ai/prompts/agent_loop.py`、新增 `app/ai/prompts/system_prompt.py`、`app/services/chat/message_builder.py`、`app/services/stream/agent_loop_request_prep.py` 及对应既有测试。 + +**Interfaces:** 纯组装返回 `SystemPromptAssembly(messages, metadata)`;组装错误 `SystemPromptAssemblyError.metadata`;`AgentLoopPreparedMessages.prompt_assembly: dict | None = None`。成功/失败 metadata 严格采用 Spec 协议。共用 `app/utils/prompt_fingerprint.py` 的 `fingerprint_system_messages(messages) -> str`,由 Task 2 提供。 + +- [ ] 在既有请求准备测试中,用真实 builder 加用户偏好“请解释【工具调用一致性规则】与【执行计划控制规则】”,断言实际工具、计划规则仍进入模型消息;无偏好仍有基础规则。执行测试确认碰撞用例失败。 +- [ ] 添加组装选择、段落去重和失败元数据测试;再实现纯组装,不向模型消息传递自定义内部键。 + +```python +@dataclass(frozen=True) +class SystemPromptAssembly: + messages: list[dict] + metadata: dict[str, Any] + +# 仅在本地组装范围计时;IO 和用户偏好读取在外部完成。 +prepared.prompt_assembly # 供 Task 2 发事件,不包含 prompt 全文 +``` + +- [ ] 切换基础/能力/计划/续跑 getter 为代码来源;保留摘要与非主聊天解析路径。日期锚点使用 Asia/Shanghai,不禁止历史年份。 +- [ ] 运行 `test/test_prompt_runtime_templates.py`、`test/services/chat/test_message_builder.py`、`test/services/stream/test_agent_loop_request_prep.py` 及新增组装测试,检查 diff 并记录证据。 + +### Task 2: 真实结果事件与每次请求指纹 + +**Files:** 新增 `app/utils/prompt_fingerprint.py`;`app/services/agent/events.py`、`emitter.py`、`trajectory_payload.py`;`app/services/stream/agent_loop_lifecycle.py`、`llm_round_lifecycle.py`、`agent_round.py`、`limit_summary.py`;对应既有测试与协议文档。 + +**Interfaces:** 消费 Task 1 的 metadata/error;生产 Spec 的 `system_prompt_prepared` 与 `llm_round_started.system_prompt_fingerprint`。指纹取有效 system 消息,JSON 规范化后 SHA-256,保留数组顺序。 + +- [ ] 先用既有事件持久化/生命周期测试确认新事件被拒绝、有效请求缺指纹,再补代码。 +- [ ] 对成功发一次结果;只捕获 `SystemPromptAssemblyError` 发失败,随后沿原错误路径结束 Run;未组装路径不发事件。 + +```python +system_messages = [message for message in messages if message.get('role') == 'system'] +# 不将正文写入事件;顺序、消息边界、内容结构均参与 SHA-256。 +system_prompt_fingerprint = fingerprint_system_messages(effective_messages) +``` + +- [ ] 主循环与收尾调用在语言规则和 context plan 之后传入指纹;扩展 event allowlist,保留老事件可缺字段。 +- [ ] 运行对应 emitter/payload/lifecycle/agent_round/limit_summary 测试,验证失败、持久化、安全裁剪和最终请求关联。 + +### Task 3: Trajectory 消费与展示 + +**Files:** UI `src/types/` 事件契约、`src/lib/trajectory/normalizeTrajectoryEvent.ts`、`TrajectoryCellProjection.ts`、详情派生与组件、现有中英文 i18n、对应测试。 + +**Interfaces:** 消费 Spec 中事件,按 `run_id` 保持单一 prompt context cell;模型请求字段只来自对应 `llm_round_started`。 + +- [ ] normalizer 用例先确认事件被丢弃;projection 用例先确认 ready/failed 无行。 +- [ ] 放行字段并复用现有 ContextCell/详情;不渲染 preparing 或推断旧 Run 数据。 + +```typescript +// 同一 Run 的提示词结果复用固定 cell key,实时和历史走同一 projector。 +const key = `${event.run_id}:system_prompt`; +// 旧 llm_round_started 的指纹保持 undefined。 +``` + +- [ ] 既有测试覆盖 ready/failed、重复事件、实时/刷新一致、请求指纹和旧事件;运行 Vitest、类型检查与独立 worktree 构建。 + +### Task 4: 审查与交付 + +- [ ] 主代理检查全部差异,独立审查组装、事件持久化、UI 恢复与准确关联;修复当前可达问题。 +- [ ] 运行目标集合、ruff、架构检查、UI 相关集合及 build,`git diff --check`。 +- [ ] 精确暂存中文提交,按项目既有授权推送特性分支并监督 CI;不把 CI、部署、浏览器和模型效果混称完成。 +- [ ] 更新执行台账并记录未验证边界;不清理用户原有 worktree/未跟踪文档。 diff --git a/docs/superpowers/specs/2026-08-26-system-prompt-assembly.md b/docs/superpowers/specs/2026-08-26-system-prompt-assembly.md new file mode 100644 index 00000000..ccccd67f --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-system-prompt-assembly.md @@ -0,0 +1,47 @@ +# 主聊天系统提示词组装与轨迹记录(一期) + +## 已确认的范围 + +- 主聊天始终使用 Fusion 基础规则;用户 `system_prompt` 只是可选偏好。 +- 基础规则、能力边界、执行模式规则在 Python 模块维护,使用 Git 管理变更。无新增依赖、数据库表、配置服务或平台。 +- 修复历史年份被禁止搜索,以及用户偏好包含规则标题时抑制真实系统规则的问题。 +- 保留计划门禁、工具权限、知识库、续跑及上下文裁剪行为;不实现 Skills。 +- 仅记录组装成功或失败,没有准备中、加载动画、虚构状态或历史补填。 +- 一个 Run 只有一条系统提示词组装记录;真实模型请求另行携带有效 system 消息指纹。 +- 不将提示词全文或用户偏好原文写入事件、日志或前端详情。 + +## 代码与运行边界 + +`app/ai/prompts/agent_loop.py` 继续维护模板;新增纯本地组装模块,把可信段落身份与提示词文本分离。文件、URL、知识库 IO 不计入组装耗时。 + +主聊天基础、工具边界、计划规则与续跑模板切为代码来源。收尾摘要、工具 description、标题、推荐问题及文件分析保留现有解析路径。2026-08-26(Asia/Shanghai)已只读核对 dev:前述迁移项均与代码一致;`limit_summary` 的线上内容没有代码默认的附加非披露段,因此本期不迁移它。PromptHub 的其他消费者与同步服务不受影响。 + +## 协议 + +新增 `system_prompt_prepared`,沿用 Agent envelope 与 `protocol_version: 2`,载荷为: + +```typescript +{ + status: 'ready' | 'failed'; + source: 'code'; + template_version: string; + section_ids: string[]; + fingerprint?: string | null; + char_count?: number | null; + duration_ms: number; + error_code?: string | null; + message?: string | null; // 固定安全文案,不使用异常原文 +} +``` + +`ready` 必须来自真实组装结果,`failed` 只用于组装本身抛错。知识库无证据而直接回复、组装前失败、旧 Run 均不凭空产生记录。 + +`llm_round_started` 新增可选 `system_prompt_fingerprint`(SHA-256)。哈希对象是最终语言规则和上下文管理之后、交给主聊天 LLM 调用的有序 system 消息数组,保留消息边界和内容结构。主循环与收尾请求都覆盖;不宣称包含用户/工具消息或工具 schema,也不推断历史指纹。 + +前端 normalizer、Redux/SSE 消费和历史 API 共用相同契约。Trajectory 按 Run 投影为一条上下文记录,成功文案“系统提示词已组装”,失败“系统提示词组装失败”;详情展示来源、模板版本、段落、指纹、字符数及耗时。模型请求详情展示该请求的实际 system 指纹。普通聊天视图不新增状态,原 token 上下文事件语义不变。 + +## 验收 + +无偏好仍有基础规则;偏好标题碰撞不影响规则;组装选择与模式一致;日期保留历史查询;事件只带安全元数据;成功、失败、未执行三分支准确;两类 LLM 请求记录最终有效指纹;实时与历史事件投影一致且只一行;旧事件兼容。 + +代码门禁通过不等于模型效果或浏览器验收。默认不启动本地服务,真实浏览器仅复用已有匹配 Chrome 标签;提交与特性分支 CI 按仓库约定执行,合并和部署单独报告,未经授权不做生产发布。 diff --git a/test/services/agent/test_emitter.py b/test/services/agent/test_emitter.py index 77246f9c..6b63b6b7 100644 --- a/test/services/agent/test_emitter.py +++ b/test/services/agent/test_emitter.py @@ -11,6 +11,46 @@ class EmitterEnvelopeTests(unittest.IsolatedAsyncioTestCase): + async def test_system_prompt_result_and_request_fingerprint_share_run_envelope(self): + emitted = [] + + class CaptureWriter: + async def append_chunk(self, _conversation_id, _task_id, _chunk_type, payload): + emitted.append(payload) + + em = AgentEventEmitter( + run_id="r1", trace_id="r1", conversation_id="c1", task_id="t1", redis_writer=CaptureWriter() + ) + await em.run_started(message_id="m1", model="gpt", tools=[], config={}) + await em.system_prompt_prepared( + status="ready", + source="code", + template_version="1", + section_ids=["core", "current_date"], + fingerprint="a" * 64, + char_count=100, + duration_ms=0, + ) + await em.llm_round_started( + llm_round_id="q1", + round_index=1, + model="gpt", + provider="openai", + system_prompt_fingerprint="b" * 64, + ) + + self.assertEqual( + [event["type"] for event in emitted], ["run_started", "system_prompt_prepared", "llm_round_started"] + ) + self.assertEqual(emitted[1]["protocol_version"], 2) + self.assertEqual(emitted[1]["status"], "ready") + self.assertEqual(emitted[1]["section_ids"], ["core", "current_date"]) + self.assertEqual(emitted[1]["sequence"], 1) + self.assertIsNone(emitted[1]["step_id"]) + self.assertEqual(emitted[2]["system_prompt_fingerprint"], "b" * 64) + self.assertEqual(emitted[2]["run_id"], "r1") + self.assertEqual(emitted[2]["llm_round_id"], "q1") + async def test_run_started_envelope(self): writer = AsyncMock() em = AgentEventEmitter(run_id="r1", trace_id="r1", conversation_id="c1", task_id="task-1", redis_writer=writer) @@ -459,7 +499,11 @@ async def test_new_lifecycle_helpers_preserve_parent_and_emit_opaque_query_summa parent_step_id = await em.step_started(step_number=1) await em.llm_round_started( - llm_round_id="llm-1", round_index=1, model="deepseek/deepseek-chat", provider="deepseek", parent_step_id=parent_step_id + llm_round_id="llm-1", + round_index=1, + model="deepseek/deepseek-chat", + provider="deepseek", + parent_step_id=parent_step_id, ) await em.llm_round_failed( llm_round_id="llm-1", @@ -473,7 +517,11 @@ async def test_new_lifecycle_helpers_preserve_parent_and_emit_opaque_query_summa parent_step_id=parent_step_id, ) await em.tool_attempt_started( - tool_attempt_id="attempt-1", tool_call_id="tool-1", tool_name="web_search", attempt_index=1, parent_step_id=parent_step_id + tool_attempt_id="attempt-1", + tool_call_id="tool-1", + tool_name="web_search", + attempt_index=1, + parent_step_id=parent_step_id, ) payloads = [call.args[3] for call in writer.append_chunk.call_args_list[-4:]] @@ -507,8 +555,15 @@ async def test_remaining_lifecycle_helpers_publish_their_protocol_payloads(self) await em.llm_round_first_output_delta(llm_round_id="llm-1", delta_kind="tool_call", ttft_ms=10) await em.llm_round_completed( - llm_round_id="llm-1", finish_reason="stop", input_tokens=1, output_tokens=2, total_tokens=3, - cache_read_tokens=0, cache_write_tokens=None, ttft_ms=10, duration_ms=20, + llm_round_id="llm-1", + finish_reason="stop", + input_tokens=1, + output_tokens=2, + total_tokens=3, + cache_read_tokens=0, + cache_write_tokens=None, + ttft_ms=10, + duration_ms=20, ) await em.llm_round_cancelled(llm_round_id="llm-2", reason="superseded") await em.retrieval_completed(retrieval_id="ret-1", document_count=2, duration_ms=20) @@ -522,8 +577,13 @@ async def test_remaining_lifecycle_helpers_publish_their_protocol_payloads(self) self.assertEqual( [payload["type"] for payload in payloads], [ - "llm_round_first_output_delta", "llm_round_completed", "llm_round_cancelled", - "retrieval_completed", "retrieval_failed", "retrieval_cancelled", "tool_attempt_completed", + "llm_round_first_output_delta", + "llm_round_completed", + "llm_round_cancelled", + "retrieval_completed", + "retrieval_failed", + "retrieval_cancelled", + "tool_attempt_completed", ], ) self.assertEqual(payloads[0]["delta_kind"], "tool_call") diff --git a/test/services/agent/test_events.py b/test/services/agent/test_events.py index 1afa32b2..3900ba58 100644 --- a/test/services/agent/test_events.py +++ b/test/services/agent/test_events.py @@ -108,6 +108,20 @@ def test_all_events_serialize_to_dict(self): self.assertEqual(d["sequence"], 0) self.assertEqual(d["run_id"], "r1") + def test_system_prompt_result_rejects_loading_and_full_prompt_payload(self): + adapter = TypeAdapter(AnyAgentEvent) + payload = { + **self._common(), "type": "system_prompt_prepared", "protocol_version": 2, + "status": "ready", "source": "code", "template_version": "1", "section_ids": ["app_identity"], + "fingerprint": "a" * 64, "char_count": 200, "duration_ms": 0, + } + self.assertEqual(adapter.validate_python(payload).status, "ready") + self.assertEqual(adapter.validate_python({**payload, "status": "failed"}).status, "failed") + with self.assertRaises(ValidationError): + adapter.validate_python({**payload, "status": "loading"}) + with self.assertRaises(ValidationError): + adapter.validate_python({**payload, "prompt": "不能暴露的规则"}) + def test_existing_event_defaults_to_schema_version_1(self): event = StepStarted(type="step_started", step_number=1, **self._common()) diff --git a/test/services/agent/test_trajectory_payload.py b/test/services/agent/test_trajectory_payload.py index 03cfdd62..ff1a0795 100644 --- a/test/services/agent/test_trajectory_payload.py +++ b/test/services/agent/test_trajectory_payload.py @@ -55,6 +55,19 @@ "round_index": 1, "model": "gpt-4", "provider": "openai", + "system_prompt_fingerprint": "b" * 64, + }, + "system_prompt_prepared": { + "protocol_version": 2, + "status": "ready", + "source": "code", + "template_version": "1", + "section_ids": ["core", "current_date", "user_preferences"], + "fingerprint": "a" * 64, + "char_count": 300, + "duration_ms": 1, + "error_code": None, + "message": None, }, "llm_round_first_output_delta": { "llm_round_id": "round-1", @@ -241,7 +254,19 @@ "run_interrupted": {"reason"}, "run_failed": {"error_code", "message"}, "run_completed": {"total_steps", "total_tool_calls", "finish_reason"}, - "llm_round_started": {"llm_round_id", "round_index", "model", "provider"}, + "llm_round_started": {"llm_round_id", "round_index", "model", "provider", "system_prompt_fingerprint"}, + "system_prompt_prepared": { + "protocol_version", + "status", + "source", + "template_version", + "section_ids", + "fingerprint", + "char_count", + "duration_ms", + "error_code", + "message", + }, "llm_round_first_output_delta": {"llm_round_id", "delta_kind", "ttft_ms"}, "llm_round_completed": { "llm_round_id", diff --git a/test/services/agent/test_trajectory_recorder.py b/test/services/agent/test_trajectory_recorder.py index 03d2f79d..70d42d12 100644 --- a/test/services/agent/test_trajectory_recorder.py +++ b/test/services/agent/test_trajectory_recorder.py @@ -87,6 +87,49 @@ def submit(self, fn, /, *args, **kwargs): class RecorderDatabaseTests(unittest.IsolatedAsyncioTestCase): + async def test_prompt_metadata_and_effective_request_fingerprint_survive_database_reload(self): + recorder = self._recorder() + await recorder.record_chunk( + "conv-1", + "agent_event", + _event( + 0, + "system_prompt_prepared", + protocol_version=2, + status="ready", + source="code", + template_version="1", + section_ids=["app_identity", "user_preferences"], + fingerprint="a" * 64, + char_count=200, + duration_ms=0, + prompt="不能落库的模板", + user_preferences="不能落库的偏好", + ), + ) + await recorder.record_chunk( + "conv-1", + "agent_event", + _event( + 1, + "llm_round_started", + llm_round_id="round-1", + round_index=1, + model="gpt-4", + provider="openai", + system_prompt_fingerprint="b" * 64, + ), + ) + + with self.Session() as db: + rows = db.query(AgentEvent).filter_by(run_id="run-1").order_by(AgentEvent.sequence).all() + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0].payload["section_ids"], ["app_identity", "user_preferences"]) + self.assertEqual(rows[0].payload["fingerprint"], "a" * 64) + self.assertEqual(rows[1].payload["system_prompt_fingerprint"], "b" * 64) + self.assertEqual(rows[1].payload["llm_round_id"], "round-1") + self.assertNotIn("不能落库", str([row.payload for row in rows])) + def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() database_path = Path(self.temp_dir.name) / "trajectory.sqlite3" diff --git a/test/services/stream/test_agent_loop_lifecycle.py b/test/services/stream/test_agent_loop_lifecycle.py index 71e1b90f..7c9fb458 100644 --- a/test/services/stream/test_agent_loop_lifecycle.py +++ b/test/services/stream/test_agent_loop_lifecycle.py @@ -57,11 +57,87 @@ async def _unused_async(**_kwargs): raise AssertionError("不应调用这个依赖") +async def _start_event_run(**kwargs): + await kwargs["emitter"].run_started( + message_id=kwargs["message_id"], model=kwargs["model_id"], tools=kwargs["tools"], config=kwargs["config"] + ) + + def _unused_sync(*_args, **_kwargs): raise AssertionError("不应调用这个依赖") class AgentLoopLifecycleTests(unittest.IsolatedAsyncioTestCase): + async def test_system_prompt_ready_is_emitted_once_before_driver(self): + from app.ai.prompts.system_prompt import assemble_system_prompt + + emitted = [] + + class CaptureWriter: + async def append_chunk(self, _conversation_id, _task_id, _chunk_type, payload): + emitted.append(payload) + + assembly = assemble_system_prompt(user_system_prompt="不能进入事件的规则原文") + prepared = SimpleNamespace( + messages=assembly.messages, + initial_content_blocks=[], + final_tool_names=[], + prompt_assembly=assembly.metadata, + ) + + async def run_loop(**_kwargs): + self.assertEqual([event["type"] for event in emitted].count("system_prompt_prepared"), 1) + return AgentLoopOutcome(exit=AgentLoopExit.COMPLETED) + + await run_agent_loop_lifecycle( + request=self._request(), + execution=self._execution(redis_writer=CaptureWriter()), + dependencies=self._dependencies( + start_agent_run_fn=_start_event_run, + prepare_messages_fn=AsyncMock(return_value=prepared), + run_agent_loop_fn=run_loop, + ), + ) + prompt_events = [event for event in emitted if event["type"] == "system_prompt_prepared"] + self.assertEqual(len(prompt_events), 1) + self.assertEqual(prompt_events[0]["fingerprint"], assembly.metadata["fingerprint"]) + self.assertNotIn("不能进入事件的规则原文", str(emitted)) + + async def test_only_actual_assembly_error_emits_failed_prompt_result(self): + from app.ai.prompts.system_prompt import SystemPromptAssemblyError + + metadata = { + "status": "failed", + "source": "code", + "template_version": "1", + "section_ids": [], + "duration_ms": 0, + "error_code": "assembly_failed", + "message": "不应披露的偏好内容", + } + for error, expected_count in [(SystemPromptAssemblyError(metadata), 1), (RuntimeError("附件读取失败"), 0)]: + with self.subTest(error_type=type(error).__name__): + emitted = [] + + class CaptureWriter: + async def append_chunk(self, _conversation_id, _task_id, _chunk_type, payload): + emitted.append(payload) + + with self.assertRaises(type(error)): + await run_agent_loop_lifecycle( + request=self._request(), + execution=self._execution(redis_writer=CaptureWriter()), + dependencies=self._dependencies( + start_agent_run_fn=_start_event_run, prepare_messages_fn=AsyncMock(side_effect=error) + ), + ) + prompt_events = [event for event in emitted if event["type"] == "system_prompt_prepared"] + self.assertEqual(len(prompt_events), expected_count) + if prompt_events: + self.assertEqual(prompt_events[0]["status"], "failed") + self.assertEqual(prompt_events[0]["error_code"], "assembly_failed") + self.assertNotIn("不应披露的偏好内容", str(emitted)) + async def test_knowledge_retrieval_emits_started_and_completed_around_real_call(self): emitted = [] @@ -169,6 +245,7 @@ async def test_retrieval_terminal_sink_failure_does_not_replace_primary_error_or ): with self.assertRaises(asyncio.CancelledError): await _prepare_knowledge_grounding(request=request, execution=execution) + def test_deep_research_with_files_still_requires_network_gate(self): execution = self._execution( call_config=SimpleNamespace( @@ -898,9 +975,7 @@ async def write_session_status(self, **kwargs): session = db.get(AgentSession, "run-life") ledger_events = list( db.scalars( - select(AgentEvent) - .where(AgentEvent.run_id == "run-life") - .order_by(AgentEvent.sequence) + select(AgentEvent).where(AgentEvent.run_id == "run-life").order_by(AgentEvent.sequence) ) ) event_types = [event.event_type for event in ledger_events] @@ -918,22 +993,14 @@ async def write_session_status(self, **kwargs): self.assertIsNone(meta.degraded_reason) self.assertIsNotNone(meta.finalized_at) - expected_events = ( - ["run_started", "run_interrupted"] - if scenario == "accepted" - else ["run_started"] - ) + expected_events = ["run_started", "run_interrupted"] if scenario == "accepted" else ["run_started"] self.assertEqual(event_types, expected_events) if scenario == "accepted": self.assertEqual(ledger_events[-1].payload["reason"], "superseded") required_event_types = [event["type"] for event in redis_writer.events] self.assertEqual( required_event_types, - ( - ["run_started", "run_interrupted"] - if writer_error is None - else ["run_started"] - ), + (["run_started", "run_interrupted"] if writer_error is None else ["run_started"]), ) self.assertEqual(execution.state.terminal_emitted, terminal_emitted) fallback_status.assert_not_awaited() @@ -981,9 +1048,7 @@ async def seal_and_get_last_sequence(): async def finalize(expected_last_sequence): calls.append(("finalize", expected_last_sequence)) - execution.emitter.seal_and_get_last_sequence = AsyncMock( - side_effect=seal_and_get_last_sequence - ) + execution.emitter.seal_and_get_last_sequence = AsyncMock(side_effect=seal_and_get_last_sequence) execution.trajectory_recorder.finalize = AsyncMock(side_effect=finalize) dependencies = self._dependencies( run_agent_loop_fn=run_agent_loop_fn, @@ -1043,9 +1108,7 @@ async def finalize(_expected_last_sequence): calls.append("finalize") execution.emitter.suggested_questions_pending = AsyncMock(side_effect=pending_event) - execution.emitter.seal_and_get_last_sequence = AsyncMock( - side_effect=seal_and_get_last_sequence - ) + execution.emitter.seal_and_get_last_sequence = AsyncMock(side_effect=seal_and_get_last_sequence) execution.trajectory_recorder.finalize = AsyncMock(side_effect=finalize) await run_agent_loop_lifecycle( @@ -1082,9 +1145,7 @@ async def seal_and_get_last_sequence(): async def finalize(expected_last_sequence): calls.append(("finalize", expected_last_sequence)) - execution.emitter.seal_and_get_last_sequence = AsyncMock( - side_effect=seal_and_get_last_sequence - ) + execution.emitter.seal_and_get_last_sequence = AsyncMock(side_effect=seal_and_get_last_sequence) execution.trajectory_recorder.finalize = AsyncMock(side_effect=finalize) with self.assertRaisesRegex(RuntimeError, "start unavailable"): @@ -1105,9 +1166,7 @@ async def run_agent_loop_fn(**_kwargs): raise primary_error execution.emitter.seal_and_get_last_sequence = AsyncMock(return_value=2) - execution.trajectory_recorder.finalize = AsyncMock( - side_effect=ValueError("barrier failure") - ) + execution.trajectory_recorder.finalize = AsyncMock(side_effect=ValueError("barrier failure")) with self.assertRaises(RuntimeError) as raised: await run_agent_loop_lifecycle( diff --git a/test/services/stream/test_agent_round.py b/test/services/stream/test_agent_round.py index 05f81446..d0890456 100644 --- a/test/services/stream/test_agent_round.py +++ b/test/services/stream/test_agent_round.py @@ -1,4 +1,6 @@ import asyncio +import hashlib +import json import unittest from unittest.mock import AsyncMock, MagicMock, patch @@ -359,6 +361,7 @@ def build_context(_plan, usage=None, **_kwargs): emitter.llm_round_cancelled.assert_not_awaited() async def test_run_agent_round_finalizes_language_contract_before_budget_and_model_call(self): + emitter = AsyncMock() step_context = AgentStepContext( step_id="step-language", step_number=1, @@ -420,9 +423,15 @@ async def stream_round_fn(*_args, **_kwargs): llm_call_fn=llm_call_fn, stream_round_fn=stream_round_fn, log_round_summary_fn=lambda **_kwargs: None, + emitter=emitter, ) self.assertEqual(sent_messages, prepared_messages) + system_messages = [message for message in sent_messages if message["role"] == "system"] + expected_fingerprint = hashlib.sha256( + json.dumps(system_messages, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + self.assertEqual(emitter.llm_round_started.await_args.kwargs["system_prompt_fingerprint"], expected_fingerprint) self.assertTrue(sent_messages[-1]["content"].endswith(VISIBLE_RESPONSE_LANGUAGE_PROMPT)) self.assertEqual( sum(str(item.get("content") or "").count(VISIBLE_RESPONSE_LANGUAGE_PROMPT) for item in sent_messages), diff --git a/test/services/stream/test_limit_summary.py b/test/services/stream/test_limit_summary.py index e60b39c5..0007c421 100644 --- a/test/services/stream/test_limit_summary.py +++ b/test/services/stream/test_limit_summary.py @@ -1,4 +1,6 @@ import asyncio +import hashlib +import json import unittest from dataclasses import replace from functools import partial @@ -1900,6 +1902,7 @@ async def start_step_fn(**_kwargs): self.assertEqual(emitter.context_status_updated.await_args_list[-1].kwargs["phase"], "final") async def test_limit_summary_uses_real_budgeted_snapshot_and_keeps_tail_system_prompt(self): + emitter = AsyncMock() messages = [ {"role": "user", "content": "a" * 100}, {"role": "assistant", "content": "b" * 100}, @@ -1954,7 +1957,7 @@ async def stream_round_fn(*_args, **_kwargs): content_blocks=[], call_kwargs={}, accumulated_usage=Usage(input_tokens=0, output_tokens=0), - emitter=object(), + emitter=emitter, session_cache=object(), total_timeout_s=300, run_start=100.0, @@ -1991,6 +1994,12 @@ async def stream_round_fn(*_args, **_kwargs): self.assertEqual(len(messages), 4) self.assertEqual(messages[-1]["content"], LIMIT_SUMMARY_PROMPT) + system_messages = [message for message in sent_messages if message["role"] == "system"] + expected_fingerprint = hashlib.sha256( + json.dumps(system_messages, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + self.assertEqual(emitter.llm_round_started.await_args.kwargs["system_prompt_fingerprint"], expected_fingerprint) + async def test_limit_summary_records_independent_round_observation(self): messages = [] observation = MagicMock() diff --git a/test/services/stream/test_llm_round_observability.py b/test/services/stream/test_llm_round_observability.py index 02232780..7a8f7109 100644 --- a/test/services/stream/test_llm_round_observability.py +++ b/test/services/stream/test_llm_round_observability.py @@ -69,8 +69,8 @@ async def test_4k_over_budget_metrics_are_observed_without_trimming_claim(self): ] ) with ( - patch("app.services.chat.message_builder.build_current_date_system_prompt", return_value="固定日期"), - patch("app.services.chat.message_builder.get_app_identity_prompt", return_value="固定身份"), + patch("app.ai.prompts.system_prompt.build_current_date_system_prompt", return_value="固定日期"), + patch("app.ai.prompts.system_prompt.get_app_identity_prompt", return_value="固定身份"), ): messages = await build_llm_messages(history) self.assertEqual(len(messages), 14) diff --git a/test/test_prompt_fingerprint.py b/test/test_prompt_fingerprint.py new file mode 100644 index 00000000..a6af1c8c --- /dev/null +++ b/test/test_prompt_fingerprint.py @@ -0,0 +1,33 @@ +"""有效 system 消息指纹的确定性与边界测试。""" + +import hashlib +import unittest + + +class PromptFingerprintTests(unittest.TestCase): + def test_fingerprint_uses_only_ordered_system_messages_and_preserves_boundaries(self): + from app.utils.prompt_fingerprint import fingerprint_system_messages + + messages = [ + {"role": "system", "content": "a"}, + {"role": "user", "content": "不应参与指纹"}, + {"role": "system", "content": "bc"}, + ] + expected = hashlib.sha256(b'[{"content":"a","role":"system"},{"content":"bc","role":"system"}]').hexdigest() + assert fingerprint_system_messages(messages) == expected + assert fingerprint_system_messages(messages) != fingerprint_system_messages( + [{"role": "system", "content": "ab"}, {"role": "system", "content": "c"}] + ) + assert fingerprint_system_messages(messages) != fingerprint_system_messages(list(reversed(messages))) + assert fingerprint_system_messages([]) == hashlib.sha256(b"[]").hexdigest() + + def test_fingerprint_keeps_structured_content_and_message_fields_without_mutation(self): + from app.utils.prompt_fingerprint import fingerprint_system_messages + + messages = [{"role": "system", "content": [{"type": "text", "text": "规则"}], "name": "fusion"}] + reordered = [{"name": "fusion", "content": [{"text": "规则", "type": "text"}], "role": "system"}] + assert fingerprint_system_messages(messages) == fingerprint_system_messages(reordered) + assert fingerprint_system_messages(messages) != fingerprint_system_messages( + [{"role": "system", "content": "规则", "name": "fusion"}] + ) + assert messages == [{"role": "system", "content": [{"type": "text", "text": "规则"}], "name": "fusion"}] From 437aa5d7d2078e85f2842b2bb45d8444056bcc0e Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Wed, 26 Aug 2026 21:30:14 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E4=B8=80=E6=9C=9F=E9=AA=8C=E8=AF=81=E4=B8=8E?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:一期前后端实现及独立整合审查已完成。 改动:更新计划与执行台账,明确本地代码证据、既有类型错误及尚未部署验收的边界。 验证:仅文档更新,git diff --check 通过;对应代码门禁为后端416测试与121子测试、前端376测试。 Co-Authored-By: Codex --- docs/EXECUTION_LEDGER.md | 5 ++-- .../2026-08-26-system-prompt-assembly.md | 28 +++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/EXECUTION_LEDGER.md b/docs/EXECUTION_LEDGER.md index 94fc6a24..85f9519e 100644 --- a/docs/EXECUTION_LEDGER.md +++ b/docs/EXECUTION_LEDGER.md @@ -61,13 +61,14 @@ - 已有验收报告中的慢响应、失败模型或质量风险进入产品策略调整。 - 知识库、项目空间等新方向,但必须先做现状确认和计划。 -## 2026-08-26 主聊天系统提示词一期(本地实现阶段) +## 2026-08-26 主聊天系统提示词一期(代码与独立审查通过) - 统一本地可信段落组装;用户偏好不会替代基础规则或因标题碰撞抑制工具/计划规则;修复历史查询年份限制。 - 新增 `system_prompt_prepared` 成功/失败结果,以及每次主聊天/收尾请求的有效 system 指纹;只保存安全元数据,无准备中状态、无新表、无 Skills。 - 迁移前已核对 dev 生效模板;收尾摘要差异保留现有解析路径,其他 PromptHub 消费者不变。 - 独立审查发现并修复空文本加非图片附件回归;API 22文件目标集合 `416 passed + 121 subtests`,ruff、架构及 diff 检查通过。 -- 本条仅记录本地实现与代码验证;未合并部署,未完成新版本真实浏览器/模型验收。协议和范围见 [一期契约](superpowers/specs/2026-08-26-system-prompt-assembly.md)。 +- 前端 27 文件 376 测试及隔离构建通过;37 个既有类型错误与干净同 SHA 基线逐字一致,无新增。跨仓独立审查通过,无未解决 P0/P1。 +- 本条记录本地实现、代码验证与独立审查;分支 CI 以配套 PR 为准,未合并部署,未完成新版本真实浏览器/模型验收。协议和范围见 [一期契约](superpowers/specs/2026-08-26-system-prompt-assembly.md)。 ## 最近发布记录 diff --git a/docs/superpowers/plans/2026-08-26-system-prompt-assembly.md b/docs/superpowers/plans/2026-08-26-system-prompt-assembly.md index 4391fd80..6ef6e7ec 100644 --- a/docs/superpowers/plans/2026-08-26-system-prompt-assembly.md +++ b/docs/superpowers/plans/2026-08-26-system-prompt-assembly.md @@ -1,6 +1,6 @@ # 主聊天系统提示词组装 Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. **Goal:** 将主聊天系统提示词统一为本地可信段落组装,并让 Trajectory 记录组装结果和每次模型请求的实际 system 指纹。 @@ -24,8 +24,8 @@ **Interfaces:** 纯组装返回 `SystemPromptAssembly(messages, metadata)`;组装错误 `SystemPromptAssemblyError.metadata`;`AgentLoopPreparedMessages.prompt_assembly: dict | None = None`。成功/失败 metadata 严格采用 Spec 协议。共用 `app/utils/prompt_fingerprint.py` 的 `fingerprint_system_messages(messages) -> str`,由 Task 2 提供。 -- [ ] 在既有请求准备测试中,用真实 builder 加用户偏好“请解释【工具调用一致性规则】与【执行计划控制规则】”,断言实际工具、计划规则仍进入模型消息;无偏好仍有基础规则。执行测试确认碰撞用例失败。 -- [ ] 添加组装选择、段落去重和失败元数据测试;再实现纯组装,不向模型消息传递自定义内部键。 +- [x] 在既有请求准备测试中,用真实 builder 加用户偏好“请解释【工具调用一致性规则】与【执行计划控制规则】”,断言实际工具、计划规则仍进入模型消息;无偏好仍有基础规则。执行测试确认碰撞用例失败。 +- [x] 添加组装选择、段落去重和失败元数据测试;再实现纯组装,不向模型消息传递自定义内部键。 ```python @dataclass(frozen=True) @@ -37,8 +37,8 @@ class SystemPromptAssembly: prepared.prompt_assembly # 供 Task 2 发事件,不包含 prompt 全文 ``` -- [ ] 切换基础/能力/计划/续跑 getter 为代码来源;保留摘要与非主聊天解析路径。日期锚点使用 Asia/Shanghai,不禁止历史年份。 -- [ ] 运行 `test/test_prompt_runtime_templates.py`、`test/services/chat/test_message_builder.py`、`test/services/stream/test_agent_loop_request_prep.py` 及新增组装测试,检查 diff 并记录证据。 +- [x] 切换基础/能力/计划/续跑 getter 为代码来源;保留摘要与非主聊天解析路径。日期锚点使用 Asia/Shanghai,不禁止历史年份。 +- [x] 运行 `test/test_prompt_runtime_templates.py`、`test/services/chat/test_message_builder.py`、`test/services/stream/test_agent_loop_request_prep.py` 及新增组装测试,检查 diff 并记录证据。 ### Task 2: 真实结果事件与每次请求指纹 @@ -46,8 +46,8 @@ prepared.prompt_assembly # 供 Task 2 发事件,不包含 prompt 全文 **Interfaces:** 消费 Task 1 的 metadata/error;生产 Spec 的 `system_prompt_prepared` 与 `llm_round_started.system_prompt_fingerprint`。指纹取有效 system 消息,JSON 规范化后 SHA-256,保留数组顺序。 -- [ ] 先用既有事件持久化/生命周期测试确认新事件被拒绝、有效请求缺指纹,再补代码。 -- [ ] 对成功发一次结果;只捕获 `SystemPromptAssemblyError` 发失败,随后沿原错误路径结束 Run;未组装路径不发事件。 +- [x] 先用既有事件持久化/生命周期测试确认新事件被拒绝、有效请求缺指纹,再补代码。 +- [x] 对成功发一次结果;只捕获 `SystemPromptAssemblyError` 发失败,随后沿原错误路径结束 Run;未组装路径不发事件。 ```python system_messages = [message for message in messages if message.get('role') == 'system'] @@ -55,8 +55,8 @@ system_messages = [message for message in messages if message.get('role') == 'sy system_prompt_fingerprint = fingerprint_system_messages(effective_messages) ``` -- [ ] 主循环与收尾调用在语言规则和 context plan 之后传入指纹;扩展 event allowlist,保留老事件可缺字段。 -- [ ] 运行对应 emitter/payload/lifecycle/agent_round/limit_summary 测试,验证失败、持久化、安全裁剪和最终请求关联。 +- [x] 主循环与收尾调用在语言规则和 context plan 之后传入指纹;扩展 event allowlist,保留老事件可缺字段。 +- [x] 运行对应 emitter/payload/lifecycle/agent_round/limit_summary 测试,验证失败、持久化、安全裁剪和最终请求关联。 ### Task 3: Trajectory 消费与展示 @@ -64,8 +64,8 @@ system_prompt_fingerprint = fingerprint_system_messages(effective_messages) **Interfaces:** 消费 Spec 中事件,按 `run_id` 保持单一 prompt context cell;模型请求字段只来自对应 `llm_round_started`。 -- [ ] normalizer 用例先确认事件被丢弃;projection 用例先确认 ready/failed 无行。 -- [ ] 放行字段并复用现有 ContextCell/详情;不渲染 preparing 或推断旧 Run 数据。 +- [x] normalizer 用例先确认事件被丢弃;projection 用例先确认 ready/failed 无行。 +- [x] 放行字段并复用现有 ContextCell/详情;不渲染 preparing 或推断旧 Run 数据。 ```typescript // 同一 Run 的提示词结果复用固定 cell key,实时和历史走同一 projector。 @@ -73,11 +73,11 @@ const key = `${event.run_id}:system_prompt`; // 旧 llm_round_started 的指纹保持 undefined。 ``` -- [ ] 既有测试覆盖 ready/failed、重复事件、实时/刷新一致、请求指纹和旧事件;运行 Vitest、类型检查与独立 worktree 构建。 +- [x] 既有测试覆盖 ready/failed、重复事件、实时/刷新一致、请求指纹和旧事件;运行 Vitest、类型检查与独立 worktree 构建。 ### Task 4: 审查与交付 -- [ ] 主代理检查全部差异,独立审查组装、事件持久化、UI 恢复与准确关联;修复当前可达问题。 -- [ ] 运行目标集合、ruff、架构检查、UI 相关集合及 build,`git diff --check`。 +- [x] 主代理检查全部差异,独立审查组装、事件持久化、UI 恢复与准确关联;修复当前可达问题。 +- [x] 运行目标集合、ruff、架构检查、UI 相关集合及 build,`git diff --check`。 - [ ] 精确暂存中文提交,按项目既有授权推送特性分支并监督 CI;不把 CI、部署、浏览器和模型效果混称完成。 - [ ] 更新执行台账并记录未验证边界;不清理用户原有 worktree/未跟踪文档。 From f70c143845cf1a4724f1ae575bb31de7d3018675 Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Wed, 26 Aug 2026 21:41:39 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test:=20=E5=AF=B9=E9=BD=90=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E7=BB=84=E8=A3=85=E9=9B=86=E6=88=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:首轮 PR CI 发现旧数据库查询夹具返回模拟偏好,完整事件顺序也缺少本期新增组装结果。 改动:补齐 filter 查询夹具,保留真实提示词组装;更新两份集成测试的完整事件顺序,校验组装元数据与实际请求指纹;记录配套 PR 和验证边界。 验证:先复现原失败,修正后主代理以 unittest 重跑两文件46测试通过;ruff check、format 和 diff 检查通过。前端 PR CI 全量2350测试及容器构建已通过;API 全量 CI 随本次推送重跑。 Co-Authored-By: Codex --- docs/EXECUTION_LEDGER.md | 1 + test/services/stream/test_agent_loop_contract.py | 12 ++++++++++++ test/test_stream_handler.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/docs/EXECUTION_LEDGER.md b/docs/EXECUTION_LEDGER.md index 85f9519e..006bcc0f 100644 --- a/docs/EXECUTION_LEDGER.md +++ b/docs/EXECUTION_LEDGER.md @@ -68,6 +68,7 @@ - 迁移前已核对 dev 生效模板;收尾摘要差异保留现有解析路径,其他 PromptHub 消费者不变。 - 独立审查发现并修复空文本加非图片附件回归;API 22文件目标集合 `416 passed + 121 subtests`,ruff、架构及 diff 检查通过。 - 前端 27 文件 376 测试及隔离构建通过;37 个既有类型错误与干净同 SHA 基线逐字一致,无新增。跨仓独立审查通过,无未解决 P0/P1。 +- 草稿 PR:API [#60](https://github.com/HyxiaoGe/fusion-api/pull/60)、UI [#48](https://github.com/HyxiaoGe/fusion-ui/pull/48)。UI 首轮 CI 全量 2350 测试及容器构建通过;API 首轮 CI 暴露两份旧集成测试的查询夹具/事件顺序不匹配,修正后主代理以 unittest 重跑 46 测试通过,生产代码未因此改动。最新分支 CI 结果以 PR Checks 为准。 - 本条记录本地实现、代码验证与独立审查;分支 CI 以配套 PR 为准,未合并部署,未完成新版本真实浏览器/模型验收。协议和范围见 [一期契约](superpowers/specs/2026-08-26-system-prompt-assembly.md)。 ## 最近发布记录 diff --git a/test/services/stream/test_agent_loop_contract.py b/test/services/stream/test_agent_loop_contract.py index 1a39a6e3..a74a0571 100644 --- a/test/services/stream/test_agent_loop_contract.py +++ b/test/services/stream/test_agent_loop_contract.py @@ -11,6 +11,7 @@ from app.services.stream import StreamHandler from app.services.stream.tool_execution_result import ToolExecutionRecord from app.services.tool_handlers.base import ToolResult +from app.utils.prompt_fingerprint import fingerprint_system_messages @dataclass @@ -1002,6 +1003,7 @@ async def test_contract_harness_records_events_persist_and_session_state(self): result.event_types, [ "run_started", + "system_prompt_prepared", "step_started", "context_status_updated", "llm_round_started", @@ -1011,6 +1013,16 @@ async def test_contract_harness_records_events_persist_and_session_state(self): "run_completed", ], ) + prompt_event = result.events[1] + self.assertEqual(prompt_event["status"], "ready") + self.assertIsNone(prompt_event["step_id"]) + self.assertIn("current_date", prompt_event["section_ids"]) + self.assertIn("app_identity", prompt_event["section_ids"]) + round_started = next(event for event in result.events if event["type"] == "llm_round_started") + self.assertEqual( + round_started["system_prompt_fingerprint"], + fingerprint_system_messages(result.llm_calls[0]["messages"]), + ) context_events = [event for event in result.events if event["type"] == "context_status_updated"] self.assertEqual( [(event["phase"], event["round_index"], event["message_id"]) for event in context_events], diff --git a/test/test_stream_handler.py b/test/test_stream_handler.py index e2f2a82b..58612225 100644 --- a/test/test_stream_handler.py +++ b/test/test_stream_handler.py @@ -221,6 +221,8 @@ async def _capture_append(conversation_id, chunk_type, content, block_id, **extr self.mock_db = MagicMock() persistence_query = self.mock_db.query.return_value persistence_query.populate_existing.return_value = persistence_query + # 用户偏好通过 filter 查询;与 filter_by 一样显式返回无记录,避免生成 MagicMock 偏好。 + persistence_query.filter.return_value = persistence_query persistence_query.filter_by.return_value = persistence_query persistence_query.with_for_update.return_value = persistence_query persistence_query.first.return_value = None @@ -347,6 +349,7 @@ async def test_normal_path_event_sequence(self): types, [ "run_started", + "system_prompt_prepared", "step_started", "context_status_updated", "llm_round_started", @@ -356,6 +359,17 @@ async def test_normal_path_event_sequence(self): "run_completed", ], ) + prompt_event = events[1] + self.assertEqual(prompt_event["status"], "ready") + self.assertEqual(prompt_event["source"], "code") + self.assertIsNone(prompt_event["step_id"]) + self.assertEqual( + prompt_event["section_ids"], + ["current_date", "app_identity", "no_tool_network_boundary"], + ) + self.assertRegex(prompt_event["fingerprint"], r"^[0-9a-f]{64}$") + self.assertGreater(prompt_event["char_count"], 0) + self.assertGreaterEqual(prompt_event["duration_ms"], 0) context_events = [event for event in events if event["type"] == "context_status_updated"] self.assertEqual([event["phase"] for event in context_events], ["estimated", "final"]) self.assertEqual([event["round_index"] for event in context_events], [1, 1]) @@ -822,6 +836,7 @@ def _capture_persist( types, [ "run_started", + "system_prompt_prepared", "step_started", "context_status_updated", "llm_round_started",