Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/app/db/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys
from pathlib import Path

from sqlalchemy import or_
from sqlmodel import Session, select

from app import paths
Expand Down Expand Up @@ -856,8 +857,14 @@ def seed_demo_data(session: Session) -> None:
)

# 桌面/单机版默认管理员账号(admin / admin)。权限只读取数据库 role 字段。
# 用 id=="admin" 或 username=="admin" 匹配:管理员可能被改了用户名(id 仍是
# admin)也可能种子时 id 就不是 admin(username 仍是 admin)——只认一边会在
# 另一边插入重复行,撞 id 主键或 (tenant_id, username) 唯一约束。
admin_user = session.exec(
select(User).where(User.tenant_id == "tenant_demo", User.username == "admin")
select(User).where(
User.tenant_id == "tenant_demo",
or_(User.id == "admin", User.username == "admin"),
)
).first()
if not admin_user:
session.add(
Expand Down
25 changes: 19 additions & 6 deletions backend/app/general_skills/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,6 @@ def _generate_plan(
"name": skill.name,
"description": skill.description,
"homepage": skill.homepage,
"markdown": skill.skill_markdown,
"package": _skill_package_payload(skill),
},
"runtime": {
Expand All @@ -321,7 +320,7 @@ def _generate_plan(
)
with llm_operation("general_skill.plan"):
raw = LLMClient(_with_min_tokens(model_config, GENERAL_SKILL_MAX_TOKENS)).generate_json(
unified_system_prompt(),
unified_system_prompt(skill.skill_markdown),
payload,
)
plan = GeneralSkillExecutionPlan.model_validate(raw)
Expand Down Expand Up @@ -455,7 +454,6 @@ def _repair_plan(
"name": skill.name,
"description": skill.description,
"homepage": skill.homepage,
"markdown": skill.skill_markdown,
"package": _skill_package_payload(skill),
},
"runtime": {
Expand All @@ -482,7 +480,7 @@ def _repair_plan(
)
with llm_operation("general_skill.repair", attempt=next_attempt):
raw = LLMClient(_with_min_tokens(model_config, GENERAL_SKILL_MAX_TOKENS)).generate_json(
unified_system_prompt(),
unified_system_prompt(skill.skill_markdown),
payload,
)
plan = GeneralSkillExecutionPlan.model_validate(raw)
Expand Down Expand Up @@ -727,7 +725,6 @@ def _review_execution_result(
"name": skill.name,
"description": skill.description,
"homepage": skill.homepage,
"markdown": _truncate(skill.skill_markdown, 6000),
"package": _skill_package_payload(skill, preview_limit=6000),
},
"runner": {
Expand All @@ -752,7 +749,7 @@ def _review_execution_result(
try:
with llm_operation("general_skill.review", attempt=attempt):
raw = LLMClient(model_config).generate_json(
unified_system_prompt(), payload
unified_system_prompt(skill.skill_markdown), payload
)
review = GeneralSkillExecutionReview.model_validate(raw).model_dump(mode="json")
except Exception as exc:
Expand Down Expand Up @@ -822,11 +819,27 @@ def _skill_files(skill: GeneralSkill) -> list[dict[str, Any]]:


def _skill_package_payload(skill: GeneralSkill, preview_limit: int = 12000) -> dict[str, Any]:
# SKILL.md's content is already sent in full via the sibling "markdown"
# field on every call site that includes a package payload; echoing it
# again here as a content_preview doubles the token cost of every plan/
# repair/review call for no benefit, since the model already has it.
markdown = str(getattr(skill, "skill_markdown", "") or "")
files = _skill_files(skill)
previews: list[dict[str, Any]] = []
remaining = preview_limit
for file in files:
content = str(file.get("content") or "")
if file.get("path") == "SKILL.md" and content == markdown:
previews.append(
{
"path": file["path"],
"size": file.get("size"),
"mime_type": file.get("mime_type"),
"content_preview": "(与上方 skill.markdown 字段内容相同,不重复展开)",
"truncated": False,
}
)
continue
preview = content[: max(0, min(len(content), remaining))]
remaining -= len(preview)
previews.append(
Expand Down
57 changes: 40 additions & 17 deletions backend/app/llm/stage_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,23 @@
}


def unified_system_prompt() -> str:
return UNIFIED_PROMPT_PATH.read_text(encoding="utf-8").strip()
def unified_system_prompt(skill_markdown: str | None = None) -> str:
base = UNIFIED_PROMPT_PATH.read_text(encoding="utf-8").strip()
markdown = (skill_markdown or "").strip()
if not markdown:
return base
# Appending the active general skill's SKILL.md here (rather than in the
# per-turn stage payload) puts it in the one message position that stays
# byte-identical across every call for this skill, so provider prefix
# caching (DeepSeek/Gemini) actually reuses it instead of re-billing the
# full text on every turn. See render_stage_user_message for the
# matching comment on the per-turn side.
return (
base
+ "\n\n---\n\n"
+ "以下是当前技能的 SKILL.md 全文,是本次调用的固定背景资料:\n\n"
+ markdown
)


def stage_payload(
Expand Down Expand Up @@ -152,28 +167,36 @@ def render_stage_user_message(
output_contract = json.dumps(
output_contract or {}, ensure_ascii=False, separators=(",", ":")
)
sections = []
# Static/highly-reused content goes first, volatile content last. Providers
# (DeepSeek, OpenAI-compatible proxies, etc.) cache the longest matching
# prefix of a request; a per-call timestamp or the user's raw message
# placed ahead of the large, stable stage instructions (e.g. a skill's
# full SKILL.md) breaks the prefix at the first byte and defeats caching
# for everything that follows, even though that content is identical
# across calls. Keeping the actual user question last also matches
# standard prompting practice (closest to where generation begins).
sections = [
f"当前阶段:\n{stage.get('phase') or '未指定'}",
(
"思考要求:\n保留完成当前阶段所需的简短思考;不要复述上下文、逐字段展开检查、"
"罗列无关备选方案或反复验证已明确的信息。得到可靠结论后立即按输出约束作答。"
),
f"阶段规则:\n{str(stage.get('instructions') or '').strip()}",
f"输出约束:\n{output_contract}",
]
if include_turn_header:
sections.append(f"用户记忆:\n{stage.get('memory') or '无'}")
sections.append(
"当前阶段独有内容:\n"
+ json.dumps(projected, ensure_ascii=False, separators=(",", ":"))
)
if include_turn_header:
sections.extend(
[
f"用户记忆:\n{stage.get('memory') or '无'}",
f"本轮时间:\n{stage.get('turn_time') or '未提供'}",
f"本轮用户输入:\n{user_message or '(空)'}",
]
)
sections.extend(
[
f"当前阶段:\n{stage.get('phase') or '未指定'}",
(
"思考要求:\n保留完成当前阶段所需的简短思考;不要复述上下文、逐字段展开检查、"
"罗列无关备选方案或反复验证已明确的信息。得到可靠结论后立即按输出约束作答。"
),
f"阶段规则:\n{str(stage.get('instructions') or '').strip()}",
"当前阶段独有内容:\n"
+ json.dumps(projected, ensure_ascii=False, separators=(",", ":")),
f"输出约束:\n{output_contract}",
]
)
return "\n\n".join(sections)


Expand Down
16 changes: 10 additions & 6 deletions backend/tests/test_llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ def test_generate_text_projects_conversation_context_messages():
assert '"pending_tasks"' not in current_input["content"]


def test_stage_input_uses_stable_history_and_puts_memory_time_and_question_first():
def test_stage_input_uses_stable_history_and_puts_instructions_before_volatile_content():
client = object.__new__(LLMClient)
client.client = _FakeOpenAIClient()
client.model = "demo-model"
Expand Down Expand Up @@ -492,13 +492,17 @@ def test_stage_input_uses_stable_history_and_puts_memory_time_and_question_first
{"role": "assistant", "content": "请说明本次需求"},
]
current = messages[-1]["content"]
assert current.startswith("用户记忆:\n- 用户偏好简洁回复\n\n本轮时间:")
assert "本轮时间:\n2026-07-13T20:30:00+08:00" in current
assert "本轮用户输入:\n我想申请报销" in current
assert "当前阶段:\nRouter" in current
# Static/stable content (phase, thinking requirement, stage instructions) must
# come first so provider-side prompt-prefix caching isn't broken by a per-call
# timestamp or the user's raw message; volatile content stays last.
assert current.startswith("当前阶段:\nRouter")
assert "思考要求:" in current
assert "保留完成当前阶段所需的简短思考" in current
assert "available_skills" in current
assert current.index("只根据技能摘要路由。") < current.index("本轮时间:")
assert current.index("available_skills") < current.index("本轮用户输入:")
assert "本轮时间:\n2026-07-13T20:30:00+08:00" in current
assert "本轮用户输入:\n我想申请报销" in current
assert current.rstrip().endswith("本轮用户输入:\n我想申请报销")
assert "memory_internal" not in current
assert sum("我想申请报销" in str(message["content"]) for message in messages) == 1

Expand Down
19 changes: 16 additions & 3 deletions backend/tests/test_unified_agent_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def fake_generate_text(self, system_prompt, payload): # noqa: ANN001
assert payload["_agent_stage"]["output_contract"]


def test_general_skill_substages_use_the_same_unified_system_prompt(monkeypatch) -> None:
def test_general_skill_substages_share_base_prompt_with_skill_caching_suffix(monkeypatch) -> None:
systems: dict[str, str] = {}

def fake_init(self, model_config): # noqa: ANN001
Expand Down Expand Up @@ -199,5 +199,18 @@ def fake_execute_plan( # noqa: ANN001
"Reflection / General Skill Review",
"Response Generator / General Skill Reply",
}
assert len(set(systems.values())) == 1
assert "统一执行引擎" in next(iter(systems.values()))
base_prompt = systems["Router / General Skill Selector"]
assert "统一执行引擎" in base_prompt
# Selector and Reply never see the full SKILL.md, so their system
# message stays on the pure base prompt regardless of which skill is
# active — that keeps it maximally cache-stable across skills.
assert systems["Response Generator / General Skill Reply"] == base_prompt
# Plan and Review embed the active skill's SKILL.md in the system
# prompt (not the per-turn payload) so repeat calls for the same skill
# hit the provider's prefix cache instead of re-billing the full text
# on every turn.
skill_aware_prompt = systems["Step Agent / General Skill Plan"]
assert systems["Reflection / General Skill Review"] == skill_aware_prompt
assert skill_aware_prompt.startswith(base_prompt)
assert skill.skill_markdown in skill_aware_prompt
assert skill_aware_prompt != base_prompt