e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
168 lines
6.6 KiB
Python
168 lines
6.6 KiB
Python
"""Structured prompt assembly for the chat agent loop."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from deeptutor.capabilities.protocol import PromptBlock
|
|
from deeptutor.core.context import UnifiedContext
|
|
from deeptutor.services.prompt.language import append_language_directive
|
|
|
|
|
|
class ChatPromptAssembler:
|
|
"""Build system prompts from explicit, category-named blocks."""
|
|
|
|
def __init__(self, *, prompts: dict[str, Any], language: str) -> None:
|
|
self.prompts = prompts
|
|
self.language = "zh" if language.lower().startswith("zh") else "en"
|
|
|
|
def system_prompt(
|
|
self,
|
|
*,
|
|
context: UnifiedContext,
|
|
tool_manifest: str,
|
|
kb_note: str = "",
|
|
deferred_tools_manifest: str = "",
|
|
notebook_manifest: str = "",
|
|
workspace_note: str = "",
|
|
capability_blocks: list[PromptBlock] | None = None,
|
|
include_tool_manifest: bool = True,
|
|
) -> str:
|
|
blocks = self.blocks(
|
|
context=context,
|
|
tool_manifest=tool_manifest,
|
|
kb_note=kb_note,
|
|
deferred_tools_manifest=deferred_tools_manifest,
|
|
notebook_manifest=notebook_manifest,
|
|
workspace_note=workspace_note,
|
|
capability_blocks=capability_blocks,
|
|
include_tool_manifest=include_tool_manifest,
|
|
)
|
|
joined = "\n\n---\n\n".join(
|
|
f"## {block.name}\n{block.content.strip()}" for block in blocks if block.content.strip()
|
|
)
|
|
return append_language_directive(joined, self.language)
|
|
|
|
def blocks(
|
|
self,
|
|
*,
|
|
context: UnifiedContext,
|
|
tool_manifest: str,
|
|
kb_note: str = "",
|
|
deferred_tools_manifest: str = "",
|
|
notebook_manifest: str = "",
|
|
workspace_note: str = "",
|
|
capability_blocks: list[PromptBlock] | None = None,
|
|
include_tool_manifest: bool = True,
|
|
) -> list[PromptBlock]:
|
|
blocks: list[PromptBlock] = [
|
|
PromptBlock("general", self._general_block(context)),
|
|
PromptBlock("runtime_policy", self._t("runtime_policy")),
|
|
PromptBlock("loop", self._t("loop.system")),
|
|
]
|
|
# Capability playbooks sit high so they frame the whole turn when active;
|
|
# empty blocks are omitted by ``system_prompt``'s join.
|
|
blocks.extend(capability_blocks or [])
|
|
if context.persona_context:
|
|
blocks.append(PromptBlock("persona_style", context.persona_context))
|
|
partner_policy = self._partner_turn_policy(context)
|
|
if partner_policy:
|
|
blocks.append(PromptBlock("partner_turn_policy", partner_policy))
|
|
if context.memory_context:
|
|
blocks.append(PromptBlock("memory", context.memory_context))
|
|
if include_tool_manifest:
|
|
tools = tool_manifest or self._fallback_empty_tool_list()
|
|
if kb_note:
|
|
tools = f"{kb_note}\n\n{tools}"
|
|
blocks.append(PromptBlock("tools", tools))
|
|
elif kb_note:
|
|
blocks.append(PromptBlock("knowledge_base_note", kb_note))
|
|
if context.skills_manifest:
|
|
blocks.append(PromptBlock("skills", context.skills_manifest))
|
|
if context.source_manifest:
|
|
blocks.append(PromptBlock("sources", context.source_manifest))
|
|
if deferred_tools_manifest:
|
|
blocks.append(PromptBlock("extended_tools", deferred_tools_manifest))
|
|
if notebook_manifest:
|
|
blocks.append(PromptBlock("notebooks", notebook_manifest))
|
|
if workspace_note:
|
|
blocks.append(PromptBlock("workspace", workspace_note))
|
|
# Volatile content deliberately gets NO system block: the KB seed
|
|
# rides in the trailing user message, so the system prompt stays
|
|
# byte-stable for the whole turn (every loop round shares one prefix).
|
|
return blocks
|
|
|
|
def _general_block(self, context: UnifiedContext) -> str:
|
|
"""Product identity, or the partner identity when one is present.
|
|
|
|
Partner turns carry ``metadata["agent_identity"]`` (user-given name +
|
|
description); their identity comes from that and the Soul block, so
|
|
the "You are DeepTutor" general is swapped for ``general_partner``.
|
|
Chat turns carry no identity and render the general block unchanged.
|
|
"""
|
|
identity = context.metadata.get("agent_identity")
|
|
name = ""
|
|
if isinstance(identity, dict):
|
|
name = str(identity.get("name") or "").strip()
|
|
if not name:
|
|
return self._t("general")
|
|
content = self._t(
|
|
"general_partner",
|
|
default='You are a companion created by the user. The name the user gave you is "{name}".',
|
|
).format(name=name)
|
|
description = str(identity.get("description") or "").strip()
|
|
if description:
|
|
description_line = self._t(
|
|
"general_partner_description",
|
|
default="The user's description of you: {description}",
|
|
).format(description=description)
|
|
content = f"{content}\n{description_line}"
|
|
return content
|
|
|
|
def _partner_turn_policy(self, context: UnifiedContext) -> str:
|
|
identity = context.metadata.get("agent_identity")
|
|
if not isinstance(identity, dict):
|
|
return ""
|
|
if not str(identity.get("name") or "").strip():
|
|
return ""
|
|
return self._t("partner_turn_policy", default="")
|
|
|
|
def user_message(
|
|
self,
|
|
*,
|
|
context: UnifiedContext,
|
|
kb_seed: str = "",
|
|
) -> str:
|
|
template = self._t("loop.user", default="{user_message}")
|
|
try:
|
|
content = template.format(user_message=context.user_message)
|
|
except (KeyError, IndexError, ValueError):
|
|
content = context.user_message
|
|
if kb_seed:
|
|
content = f"{content}\n\n{kb_seed}"
|
|
return content
|
|
|
|
def finish_exhausted_instruction(self) -> str:
|
|
return self._t(
|
|
"loop.finish_exhausted",
|
|
default=(
|
|
"The round budget ran out before every gap was closed. Stop "
|
|
"calling tools and answer now with what you have, noting "
|
|
"briefly what remains uncertain."
|
|
),
|
|
)
|
|
|
|
def _fallback_empty_tool_list(self) -> str:
|
|
return "- 无" if self.language == "zh" else "- none"
|
|
|
|
def _t(self, key: str, default: str = "") -> str:
|
|
value: Any = self.prompts
|
|
for part in key.split("."):
|
|
if not isinstance(value, dict) or part not in value:
|
|
return default
|
|
value = value[part]
|
|
return value if isinstance(value, str) else default
|
|
|
|
|
|
__all__ = ["ChatPromptAssembler", "PromptBlock"]
|