Skip to content

BaseTool Prompt 注入能力增强(prompt_position + format_2_prompt)

字段
状态 PRD v7(S1/S2/S3 已落地,S4 待落地)
日期 2026-05-08
v2 变更 (1) 命名收敛 context_positionprompt_position;(2) 父类框架方法 + 子类业务点双层(参考 run/_run);(3) 子类返回 str \| BasePrompt,父类自动归一化;(4) LLM 注入器从「字符串桶聚合」退化为「BasePrompt 临时合并到 *_prompt 列表」
v3 变更 撤回 v2 的 ToolContextPrompt(专用类)→ 改为新增 NormalizePrompt:BasePrompt 体系的最低限度具体化(与 AdditionalInfoPrompt 同形 —— 持有 template,但不裁剪 ctx,全字段 model_dump 后注入 template.render);BaseTool 框架兜底将工具 str 包成 NormalizePrompt,并在此唯一一处自动转义字面花括号 —— 工具 dev 无需了解 fstring 转义规则
v4 变更 双层方法名 render_context / _render_contextformat_2_prompt / _format_2_prompt,与 BasePrompt 体系 format_2_str / format_2_multimodal 命名同构;调用链路 tool.format_2_prompt(ctx) → BasePrompt → prompt.format_2_str(ctx) → str 自描述
v5 变更 prompt_position tuple 内允许 Nonetuple[ChatPos \| None, DeskPos \| None] —— 支持单边注入语义(如"仅 ChatLLM 注入,DeskLLM 下沉默")。校验规则:覆写 _format_2_prompt 时至少一边非 None;(None, None) 拒绝(与 prompt_position = None 重复)。把 v4 "强制双适配"细化为「强制思考 + 显式选择」,避免被迫填凑数位
v6 变更 合法注入位收紧 —— 排除「用户意图表达位」与「系统维护的历史快照位」。DeskLLM 合法位从 7 → 4:purpose_prompt / prefix_prompt 是用户意图核心(工具不可篡改用户原话,否则 bug 导致意图紊乱难追溯),original_desk_screenshot_prompt 是系统维护的初始快照(属于历史,工具不可改写历史,否则破坏 desk 时间线因果性)。在 Literal 定义处加 inline 注释,给后来开发者明确判据
v7 变更 S1 落地复盘后的设计收敛:(1) PromptContext 不持工具引用 —— 取消 v6 §3.4 的 _active_tool_instances PrivateAttr 设计,BaseLLM construct_prompt_context(tools=...) 入参本身就持有 BaseTool list,绕道 ctx 是没必要的中转;(2) BaseLLM 取消 _llm_family ClassVar,注入器方法名改为 _collect_dynamic_prompts(prompt_ctx, tools, position_index: int),子类(ChatLLM/各 DeskLLM adapter)调用端立位传 0 / 1,避免引入额外 ClassVar 间接层;(3) dynamic_prompts 是装配方法内的短生命周期局部 dict —— 不挂在 ctx 上、不挂在 LLM 实例上,方法内即用即弃;(4) 命名升格tool_context_bucketsdynamic_prompts,为未来非工具 provider(如 Memory)留出语义空间
作者 JQQ + Claude
涉及模块 tfrobot/drive/tool/base.pytfrobot/brain/chain/llms/base.pytfrobot/brain/chain/llms/chat_llm.pytfrobot/brain/chain/llms/generation_llms/desk_llm/*.pytfrobot/brain/chain/prompt/normalize_prompt.pytfrobot/brain/chain/prompt/__init__.pytfrobot/schema/exceptions.py
关联 Epic / Issue TFROB-463(Epic)/ TFROB-464(S1,已落地)/ TFROB-465(S2)/ TFROB-466(S3)/ TFROB-467(S4)

一、背景与问题

1.1 现状

BaseTool 现有契约是纯函数式

LLM 决策调用 → run(tool_params) → ToolReturn → 写回 intermediate_msgs
                                       ↑
                              下一轮 LLM 调用读到 ToolReturn 文本

工具实例虽然 connect_to_neural 后长期挂在 Drive → Robot 上,但框架层从未给工具一个「在 LLM 调用前主动注入上下文」的位置。工具想跨调用持有状态时,只剩两条非正规路径:

路径 问题
Path A. 利用 CurrentInput.additional_info / runtime_vars 这两个字段本来语义是"本次 LLM 调用的临时槽",生命周期与单次 complete() / chain.run() 同寿。无法跨用户输入存活
另外语义被滥用:状态混在 trace / response_format 等运行时变量里,越来越难拆。
Path B. 利用 ChainCtx ChainCtx 生命周期粘在 Chain 状态机上,跨 Chain(DCChains 子链 / SeqChains)传递语义复杂,TFROB-242 已为此打过一轮补丁;让工具状态再来重蹈一遍是反向架构选择。

1.2 典型反例:TFNotes 工具

设想一个让 LLM 在工作过程中"做笔记,并在下一轮决策时能看到笔记内容"的工具:

T0  user: 我想分析 PDF
T0' LLM: TFNotes.add("待办:先抽取目录")
T1  user: 你刚才记了什么?             ← 跨 user input 仍要让 LLM 看到笔记
T1' LLM: 引用 T0' 的笔记决策下一步

要做到 T1 时让 LLM "看到" T0' 写下的笔记,需要工具能在每次 LLM 调用的 prompt 构建阶段主动注入自己的状态视图。这是当前 BaseTool 不具备的能力。

1.3 核心矛盾

工具实例长期挂载,但只在被 LLM 决策调用时才说话。状态化的工具天然要求"在 LLM 决策之前先把世界观注入它的 prompt"。

这是一个双向通信缺口: - 现有:LLM → Tool(run()) - 缺失:Tool → LLM(注入上下文)


二、设计目标

# 目标 解释
G1 给 BaseTool 一个标准的 prompt 注入通路 工具能在每次 LLM 调用时,向指定 *_prompt 段位字段贡献一个 BasePrompt —— 最终参与现有 format_2_str 渲染流水线,与 LLM 配置态的 *_prompt 同等待遇
G2 覆写即强制思考 + 显式选择 一旦工具覆写 _format_2_prompt,必须声明 prompt_position: tuple[ChatPos \| None, DeskPos \| None] 且至少一边非 None。可双边注入 (ChatPos, DeskPos),也可单边 (ChatPos, None) / (None, DeskPos) —— 沉默是显式选择,不是默认行为。这避免某类 LLM 下"悄悄失效",又不强迫开发者填凑数位
G3 零侵入 现有工具 现有 30 个 BaseTool 子类(PyIDE 系列 14 / Search 4 / MCP / DSL / Iteration / Recall / ExpandContext / Trafilatura / RetrieveTool / Tool / InvalidTool 等)一行代码不改
G4 状态存储完全由工具自治 框架不规定状态存哪、生命周期多长。文件 / DB / 内存 / additional_info / 自管 dict —— 工具说了算
G5 多工具同位共存 同一段位有多个工具贡献时,按本次调用 tools 列表顺序合并到 *_prompt 列表后端,参与同一次渲染循环(不是字符串拼接,是 BasePrompt 列表合并
G6 触发面最小化 未声明 prompt_position 的工具完全不触发 format_2_prompt 调用;仅当工具同时 ∈ active tools ∧ 已声明 prompt_position 时才参与本轮注入

显式不做的事(Non-Goals)

  • ❌ 不为工具状态提供存储抽象(StateStore / StateScope 都不做)。这是工具的事。
  • ❌ 不做去重 / 优先级 / role tagging 等更复杂的注入聚合策略(如 ContextChunk 模型)。后续按需再加。
  • ❌ 不在本期支持第三类 LLM(如 BaseLLM 直接子类、未来 VisionLLM / AgentLLM)。tuple[Chat, Desk] 可未来扩展为 Mapping[Family, Position],本期落点 tuple。
  • ❌ 不把 intermediate_msgs / conversation / reformat_input_prompt 这类消息流字段用户输入替换字段纳入合法注入目标。注入目标限定在 LLM 配置态的 list[BasePrompt] 字段(ChatLLM 的 4 个 system-role 段位 + DeskLLM 的 7 个段位,详见 §五 合法位选择表)。

三、设计方案

3.1 BaseTool API 增强:父类框架 format_2_prompt + 子类 _format_2_prompt 双层

参考 BaseTool.run / BaseTool._run 已经验证多年的双层模式 —— 父类做"边界统一 + 类型归一化",子类只关心业务返回。

# tfrobot/drive/tool/base.py

from typing import ClassVar, Literal, TYPE_CHECKING

# =====================================================================
# 合法注入位定义
# =====================================================================
# 收口原则(两条铁律 + 一条架构边界):
#
# 铁律 1:禁止注入「用户意图表达位」
#   工具不可参与用户原话/任务目的的表达。一旦允许,工具的封装 bug 会
#   直接污染用户意图,导致 LLM 决策依据是被工具改写过的"用户输入",
#   bug 现场极难溯源(用户看不见自己说的话被改了什么)。
#
# 铁律 2:禁止注入「系统维护的历史快照位」
#   历史是系统对 desk/world 的事实记录,工具不可改写历史;否则 desk
#   时间线的因果性被破坏,无法回放、无法 audit。
#
# 架构边界:仅 list[BasePrompt] 字段
#   conversation_msgs / intermediate_msgs 是 list[BaseMessage](消息流),
#   不在 LLM "*_prompt" 配置态,架构上本来就不是注入位。
# =====================================================================

ChatLLMPromptPosition = Literal[
    "system_msg_prompt",
    # ✓ 系统消息位。角色、全局背景、长期记忆等。最常用注入位。

    "before_input_msg_prompt",
    # ✓ 用户输入前的引导消息。
    #   注:紧贴用户输入但不是用户输入本身(来自 LLM 实例配置的模板,
    #   消息 role 是 system),所以工具可写。

    "after_input_msg_prompt",
    # ✓ 用户输入后的补充消息。同 before_input_msg_prompt 的判据。

    "after_intermediate_msg_prompt",
    # ✓ 中间消息后的总结位。

    # ===== 以下为 ChatLLM 不收为注入位的段位(reference 注释,不写入 Literal)=====
    #
    # ✗ reformat_input_prompt(铁律 1:用户意图表达位)
    #   该段的语义是「**替换**用户输入文本」—— 工具一旦写到这里,等于把
    #   用户原话改写后再交给 LLM,意图污染最严重。
    #
    # ✗ conversation_msgs(架构边界)
    #   list[BaseMessage],不是 list[BasePrompt]。是 LLM 调用 runtime 状态,
    #   不在配置态。工具想反映历史可以读 prompt_ctx.conversation 后渲染到合法位。
    #
    # ✗ intermediate_msgs(架构边界)
    #   同上,list[BaseMessage]。工具产生的中间结果该走 ToolReturn → intermediate_msgs
    #   的标准链路,不该在 prompt 配置态绕一遍。
]


DeskLLMPromptPosition = Literal[
    "system_prompt",
    # ✓ 系统消息位。同 ChatLLM.system_msg_prompt 判据。

    "instruction_prompt",
    # ✓ few-shot 示例区。"经验回放"类工具可注入此位。

    "intermediate_prompt",
    # ✓ 中间动作位。

    "current_desk_screenshot_prompt",
    # ✓ 当前环境实时状态。是 live 视图(与 original_desk_screenshot_prompt 的
    #   "历史快照"语义对偶),工具贴 desk 的额外维度可注入此位。

    # ===== 以下为 DeskLLM 不收为注入位的段位(reference 注释,不写入 Literal)=====
    #
    # ✗ original_desk_screenshot_prompt(铁律 2:系统维护的历史快照位)
    #   字段语义明文是「任务初始时的工作环境状态」,由系统在任务起点记录后
    #   全程不变。工具改写它 = 改写 desk 历史,破坏因果性、丢失 audit 能力。
    #
    # ✗ purpose_prompt(铁律 1:用户意图表达位)
    #   字段语义明文是「任务目的提示,一般是用户输入或当前任务描述」。
    #   工具不可篡改用户当前任务的描述。
    #
    # ✗ prefix_prompt(铁律 1:用户意图表达位)
    #   该段位于所有 prompt 最末尾、紧贴 LLM 输出,是对 LLM 行为的"最后一句话",
    #   位置敏感性最高。工具一旦在此注入,会直接劫持 LLM 当前输出形态,
    #   即使语义上看似"行事风格前缀",工程上仍按用户意图位对待,禁止工具染指。
]


class BaseTool(TFNeuralModel, ABC):

    # === 新增 ClassVar ===
    prompt_position: ClassVar[
        tuple[ChatLLMPromptPosition | None, DeskLLMPromptPosition | None] | None
    ] = None
    """
    Prompt 注入位。元组形如 (chat_position, desk_position),每一边可为 None 表示该 LLM family 下不注入。

    取值约定(覆写 _format_2_prompt 时必须显式设置;__init_subclass__ 强校验):
      - None                          —— 未覆写 _format_2_prompt,零接入(默认)
      - (ChatPos, DeskPos)            —— 双边注入(多数状态化工具)
      - (ChatPos, None)               —— 仅 ChatLLM 注入,DeskLLM 下显式沉默
      - (None, DeskPos)               —— 反之
      - (None, None)                  —— 拒绝:与 prompt_position = None 重复,请改用 None
    """

    # === 父类框架方法(子类不应覆写)===
    def format_2_prompt(self, prompt_ctx: "PromptContext") -> "BasePrompt | None":
        """
        框架方法。在每次 LLM 调用 prompt 构建阶段,由 LLM 注入器对 active tools 调用。

        生命周期:
            _format_2_prompt (子类业务) → 类型归一化 → BasePrompt | None

        - 子类返回 `str` → 自动包成 `NormalizePrompt(template=FStrPromptTemplate(
              templates={DEFAULT: <str 字面花括号已转义>}))`,**这是框架内唯一一处自动
              转义字面 `{` / `}` 的地方** —— 工具 dev 不必了解 fstring placeholder 规则。
        - 子类返回 `BasePrompt`(如自定义 NormalizePrompt / Jinja2 prompt / multimodal prompt)→ 原样透传
        - 子类返回 `""` 或 `None` → 框架返回 `None`,注入器跳过该工具贡献
        - 子类抛异常 → 框架降级为 `None` + 记 ERROR 日志(保 LLM 调用主链路不挂掉)

        关键约束:
        - 工具状态存哪由工具自己决定(文件 / DB / 内存 / additional_info / neural 召回均可)。
          框架仅承诺 prompt_ctx 里的字段在调用时已完整构建。
        - 此方法每次 LLM 调用(前提工具 ∈ active tools)都会被触发,实现需保证幂等且廉价。
          昂贵 IO 应在 `_run` / `_async_run` 内 prefetch,`_format_2_prompt` 只读已落地状态。
        """
        if self.__class__._format_2_prompt is BaseTool._format_2_prompt:
            # 子类未覆写 → 默认无注入
            return None
        try:
            raw = self._format_2_prompt(prompt_ctx)
        except Exception as e:
            logger.error(
                "Tool %s format_2_prompt failed, falling back to None: %s",
                self.tool_name, e, exc_info=True,
            )
            return None
        if raw is None or raw == "":
            return None
        if isinstance(raw, str):
            return self._wrap_str_as_normalize_prompt(raw)
        if isinstance(raw, BasePrompt):
            return raw
        raise TFProtocolError(
            f"Tool {self.tool_name}._format_2_prompt() must return str | BasePrompt | None, "
            f"got {type(raw).__name__}",
            protocol="tool_format_2_prompt",
        )

    @staticmethod
    def _wrap_str_as_normalize_prompt(text: str) -> "BasePrompt":
        """
        把字面 str 包装为 NormalizePrompt(FStrPromptTemplate)。

        ★ 框架内唯一一处自动花括号转义点:
        字面文本里的 `{` `}` 一旦进 fstring 模板就是 placeholder,会触发 KeyError
        (已知踩坑见 memory `feedback_fstring_template_brace_escape.md`)。
        本方法负责双倍转义,让工具 dev 写字面 str 时不必关心 fstring 规则。

        如果工具想显式控制模板(如 Jinja2 / 多 locale),返回完整 BasePrompt 即可,
        不会经过本兜底路径。
        """
        from tfrobot.brain.chain.prompt.normalize_prompt import NormalizePrompt
        from tfrobot.brain.chain.prompt.template.f_string_template import FStrPromptTemplate
        from tfrobot.schema.types import Locale

        escaped = text.replace("{", "{{").replace("}", "}}")
        return NormalizePrompt(
            template=FStrPromptTemplate(
                templates={Locale.DEFAULT: escaped},
                active_language=Locale.DEFAULT,
            )
        )

    # === 子类业务点(参考 _run)===
    def _format_2_prompt(self, prompt_ctx: "PromptContext") -> "str | BasePrompt | None":
        """
        子类业务点。与 `_run` 同构设计:父类只做归一化,业务自由返回。

        返回值约定:
        - `str`:字面注入文本,框架自动转义字面 `{` `}` 后包 `NormalizePrompt(FStrPromptTemplate(templates={DEFAULT: <escaped>}))` —— 工具 dev 无需懂 fstring placeholder 规则
        - `BasePrompt`:高级用法 —— 工具想用 i18n / multimodal / 自定义模板时可直接返回 prompt 实例
        - `""` / `None`:本次无内容,跳过

        默认实现返回 None,零侵入现有工具。
        """
        return None

为什么 _format_2_prompt 不传 llm_family / position

讨论中的另一选项是 _format_2_prompt(prompt_ctx, llm_family, position) -> ...最终决策:只传 prompt_ctx。理由:

  1. 工具自己定义了 prompt_position tuple,已经把"chat 时去哪、desk 时去哪"显式写在类上;不需要在每次回调里告诉它"现在是 chat 还是 desk"。
  2. 同一工具在两个 LLM 类型下渲染相同内容是绝大多数情况(笔记、用户偏好、工作记忆都不分 chat/desk)。需要差异化的工具可以从 prompt_ctx.neural 自己推断或用 contextvars。
  3. 接口越简单越不易被滥用。

为什么 format_2_prompt / _format_2_prompt 不是 @abstractmethod

会让 13+ 个现有工具(MCP / IDEs / Search / DSL / Iteration / Recall …)全部强制重写一遍空方法,纯破坏面没有任何收益。默认 _format_2_prompt 返回 None + __init_subclass__ 在覆写时强制要求 prompt_position,是更好的边界。

为什么不复用 AdditionalInfoPrompt,而新建 NormalizePrompt

仓库里已存在 AdditionalInfoPrompttfrobot/brain/chain/prompt/additional_info_prompt.py:43)以及 MemoPrompt / KnowledgePrompt 等若干语义化的 BasePrompt 子类。它们的本质都是把复杂的 PromptContext 简化 —— 各自聚焦于 ctx 的一个语义子集(additional_info / memo / knowledge / ...),让 prompt 撰写人只需要了解局部变量,降低写模板时引用 ctx 路径的长度与重复。

但缺一个最低限度的具体化:当撰写人就是想要"全 ctx 注入模板自己写"时,没有可实例化的 BasePrompt 子类能直接用(BasePrompt 是 ABC)。

NormalizePrompt 就是这一层补全。它不绑定工具语义 —— 工具注入只是它的第一个消费者。

3.1.5 NormalizePrompt 设计

# tfrobot/brain/chain/prompt/normalize_prompt.py(新建)

import warnings
from pathlib import Path
from typing import Optional

from pydantic_core import Url

from tfrobot.brain.chain.prompt.base import BasePrompt
from tfrobot.brain.chain.prompt.template.prompt_template import BasePromptTemplate
from tfrobot.schema.brain.chain.prompt.context_vars import PromptContext
from tfrobot.schema.meta.tf_field import TFField, TFFieldMeta


class NormalizePrompt(BasePrompt):
    """
    BasePrompt 体系的最低限度具体化。

    与 AdditionalInfoPrompt / MemoPrompt / KnowledgePrompt 等不同:
    - 那些类的本质是「把复杂的 PromptContext 简化」—— 各自聚焦 ctx 的一个语义子集,
      让模板撰写人只需要了解局部变量。
    - NormalizePrompt 的本质是「不简化」—— 直接把 ctx.model_dump() 作为 vars
      注入 template.render,让撰写人面对全 ctx,自由引用任何字段。

    适用场景:
    1. BaseTool._format_2_prompt() 返回 str 时的框架兜底(自动转义花括号后包入此类)
    2. 任何想直接基于全 ctx 写一段模板的开发者,不愿/不需要新建专用 BasePrompt 子类
    """

    template: BasePromptTemplate = TFField(
        ...,
        title="模板",
        description="任意 BasePromptTemplate(FStrPromptTemplate / Jinja2Template 等),"
                    "渲染时拿 ctx.model_dump(mode='json') 作为 vars。",
        tf_meta=TFFieldMeta(editable=True, is_independent_storage=True),
    )

    def format_2_str(self, ctx: PromptContext) -> str:
        vars_dict = ctx.model_dump(mode="json")
        try:
            return self.template.render(vars_dict)
        except Exception as e:
            warnings.warn(f"NormalizePrompt render failed: {e}")
            return ""

    def format_2_multimodal(self, ctx: PromptContext) -> tuple[str, dict[str, dict[str, Path | Url | bytes]]]:
        vars_dict = ctx.model_dump(mode="json")
        try:
            return self.template.multimodal_render(vars_dict)
        except Exception as e:
            warnings.warn(f"NormalizePrompt multimodal render failed: {e}")
            return "", {}

约 40 行。覆盖 BasePrompt 两个抽象方法。与现有 AdditionalInfoPrompt 同形(都持有 template),只是不裁剪 ctx。

花括号转义的责任收敛

NormalizePrompt 自身不知道字面 / 模板的区别 —— 它永远走 template.render。当工具 _format_2_prompt 返回字面 str 时,框架在 BaseTool._wrap_str_as_normalize_prompt 这一处统一执行 text.replace("{","{{").replace("}","}}")全仓只有这一处转义

  • 工具 dev 写 _format_2_prompt 返回字面 str 时,写正常 Python str,不必懂 fstring 规则
  • 工具 dev 写 _format_2_prompt 返回 BasePrompt(NormalizePrompt / 自家其它子类)时,自己控制模板,自己负责转义
  • NormalizePrompt 在被开发者直接实例化时(场景 2),开发者写模板时按 fstring/Jinja2 规范写花括号,与现有 AdditionalInfoPrompt 等其它 BasePrompt 子类一致 —— 这是"使用模板系统"的代价,不是 NormalizePrompt 的特殊负担

3.2 PromptContext 是什么、不是什么

复用现有 PromptContext不新增字段。工具可读:

字段 用途
user_input.input / additional_info 当前用户输入(含 current_input.additional_kwargs
conversation 历史会话
runtime_vars 本次 LLM 调用的运行时变量(read-only 视为约定)
neural 通过 get_neural_context(tag) 信号召回外部组件状态
tools 当前 active tools(注意已转换为 PromptTool

工具想存"跨调用状态"时的几种典型路径(框架不规定,仅提供能力): 1. 写自家的 dict/数据库/文件,在 format_2_prompt 时读出格式化。 2. 通过 prompt_ctx.neural 调外部 Memory 模块取出。 3. 把状态序列化到自家维护的某个文件路径,每次 render 时反序列化。

3.3 LLM 侧注入逻辑

由于 format_2_prompt 已经把所有工具贡献归一化为 BasePrompt | None,注入器不做字符串拼接,而是把 BasePrompt 实例临时合并到对应 *_prompt 列表参与现有 format_2_str 循环

设计原则(v7 复盘)

  1. dynamic_prompts 是短生命周期局部 dict:不挂 PromptContext,不挂 LLM 实例,只在装配方法内活一次;每次 LLM 调用 → 现遍历 tools → 现得 dict → 现合并 → 现弃。
  2. BaseLLM 不引入 family ClassVar:子类调用端本来就在 family 特定的方法体内,立位传 position_index=0(chat)或 1(desk)即可;多一个 _llm_family: ClassVar 是无意义间接层。
  3. tools 走入参,不绕 ctxBaseLLM.construct_prompt_context(tools=...) 已经把 BaseTool list 拿到,装配方法(ChatLLM 的 format_to_request_msgs / 各 DeskLLM adapter 的拼装段)通过传参或 caller 链路直接消费这份 list。

BaseLLM 公共注入器

# tfrobot/brain/chain/llms/base.py 新增

class BaseLLM(...):
    def _collect_dynamic_prompts(
        self,
        prompt_ctx: PromptContext,
        tools: list[BaseTool] | None,
        position_index: int,  # 0 = chat, 1 = desk;caller 已在 family 特定方法内,立位传值
    ) -> dict[str, list[BasePrompt]]:
        """
        遍历 active BaseTool,调每个的 format_2_prompt,按 prompt_position 桶聚合。
        返回 dict[position_name, list[BasePrompt]],顺序 = tools 入参顺序。
        返回值应作为 caller 局部变量即用即弃,不应回写 ctx 或 self。

        三层短路:
          1. tool.prompt_position is None                 → 未声明,跳过
          2. tool.prompt_position[position_index] is None → 该 family 下显式沉默,连 format_2_prompt 都不调(避免无谓 IO)
          3. tool.format_2_prompt(ctx) is None            → 状态为空,跳过
        """
        if not tools:
            return {}
        buckets: dict[str, list[BasePrompt]] = {}
        for tool in tools:
            if not isinstance(tool, BaseTool):
                continue
            if tool.prompt_position is None:
                continue
            pos = tool.prompt_position[position_index]
            if pos is None:
                continue
            prompt = tool.format_2_prompt(prompt_ctx)  # 父类已归一化为 BasePrompt | None
            if prompt is None:
                continue
            buckets.setdefault(pos, []).append(prompt)
        return buckets

ChatLLM 接入点

ChatLLM.format_to_request_msgs 是 chat 共通装配方法(落点 tfrobot/brain/chain/llms/chat_llm.py)。S2 落地时给它加 tools 入参(caller complete() 本就持有 tools),段位 4 个独立合并:

# 伪代码示意
class ChatLLM(BaseLLM):
    def format_to_request_msgs(self, current_input, prompt_ctx, tools=None):
        buckets = self._collect_dynamic_prompts(prompt_ctx, tools, 0)  # chat → 立位传 0
        # 4 个 chat 合法段位临时合并:
        head_system_msg_prompts = self.system_msg_prompt + buckets.get("system_msg_prompt", [])
        head_system_msg_content = "\n".join(
            [p.format_2_str(prompt_ctx) for p in head_system_msg_prompts]
        )
        # before_input_msg_prompt / after_input_msg_prompt / after_intermediate_msg_prompt 同款
        ...

关键不变量self.system_msg_prompt 等实例字段绝不被修改,只在局部表达式里临时合并 —— 保持 LLM 实例的 prompt 配置态不可变。

DeskLLM 接入点

DeskLLM 没有共通 format_to_request_msgs,7 段位拼装在各 adapter(openai_desk / claude_desk / gemini_desk / deepseek_desk / ollama_desk / sglang_desk / openai_gen_desk / tencent_ds_desk)中分散实现。各 adapter 在自己的拼装段调用 _collect_dynamic_prompts(prompt_ctx, tools, 1)(desk → 立位传 1),合并到 4 个 v6 合法段位:

段位 是否合并 dynamic_prompts
system_prompt
instruction_prompt
intermediate_prompt
current_desk_screenshot_prompt
original_desk_screenshot_prompt ✗(铁律 2:历史快照)
purpose_prompt ✗(铁律 1:用户意图)
prefix_prompt ✗(铁律 1:用户意图)

S3 第一步 spike 决定是否抽 DeskLLM 共通拼装方法(详见 §10.1 风险 §3);如果不抽,各 adapter 内单独接入。

为什么这样更简洁

  1. 零字符串胶水:注入器只负责把 BasePrompt 列表临时合并到 *_prompt 列表,渲染由现有 format_2_str 循环统一处理。隔离边界清晰。
  2. 工具享受 BasePrompt 全套能力format_2_multimodal(多模态附件)、additional_kwargs_schema(参数 schema 上抛)、always_render(条件渲染)都自动可用。
  3. 配置态不可变:临时合并发生在表达式层级,不污染 self.*_prompt,不会跨 call 累积。
  4. 零中转字段:dynamic_prompts dict 是装配方法局部变量,不挂 ctx 不挂 self;ctx 只持纯渲染数据,self 只持配置态。
  5. 空值天然跳过format_2_prompt 返回 None 的工具不进桶,对应 *_prompt 列表保持原样 —— 不会出现"留空段"。

拼接顺序与去重

  • 顺序:严格按 tools 入参列表顺序(即本次 LLM 调用 complete(tools=[...]) 传入顺序)。
  • 去重:在 _collect_dynamic_prompts 入口可对 toolsid(tool) 去重(防同一工具实例被传两次);S2 落地时按需加。

reformat_input_prompt 的处理

不支持。原因:reformat_input_prompt 的语义是"用模板替换用户输入文本",把工具上下文塞进去会污染用户消息,违背 ChatLLM 7 段式语义。

3.4 工具引用持有:BaseLLM 入参,不绕 PromptContext

BaseLLM.construct_prompt_context(tools=...) 已经把 BaseTool list 作为入参拿到,装配方法(ChatLLM 的 format_to_request_msgs / 各 DeskLLM adapter 的拼装段)通过传参或 caller 链路直接消费这份 list,不需要 PromptContext 这一侧再额外持引用

PromptContext.tools 字段保持现有 list[Annotated[PromptTool | BaseTool, ...]] union 形态 + validate_context 转 PromptTool 视图的现状(用于 prompt 模板内 {{ tools[i].name }} 等引用);工具的 BasePrompt 注入是一条独立的路径,由 LLM 装配阶段持有 tools 入参完成。两条路径不交叉

  • ctx.tools = "工具描述视图"(PromptTool,给 prompt 模板用)
  • BaseLLM 局部 tools 入参 = "工具实例列表"(BaseTool,给注入器调 format_2_prompt 用)

v6 → v7 演进:v6 设计在 PromptContext 加 _active_tool_instances: list[BaseTool] = PrivateAttr(...) 持引用,让 LLM 注入器从 ctx 读。S1 落地时复盘发现:BaseLLM 本来就持有 tools 入参,绕道 ctx 是没必要的中转,且让 schema 层背上 BaseTool 类型依赖。v7 移除此 PrivateAttr,PromptContext 回归"纯渲染数据"职责。

3.5 校验:prompt_position 是否合法

三层校验栈

时机 触发 说明
L1 类型注解(mypy/pyright 静态期) IDE / make lint 写错位串如 "foobar" Literal[...] 约束在 type-check 阶段拒绝。这是用户问"Pydantic 不应该天然校验"的最贴近答案 —— 静态类型检查器会在赋值表达式上拒绝非法字面量。
L2 __init_subclass__ 运行时校验 import / class 定义时 子类设置了非法 tuple、或覆写了 _format_2_prompt 但忘填 prompt_position 复用 BaseTool 现有 __init_subclass__ 添加几行检查(与已有 RegisterMap、span_decorator 在同一处)
L3 Pydantic field-level本期不做 实例化时 n/a 因为 prompt_positionClassVarPydantic 默认完全跳过 ClassVar(这是 Pydantic 的契约,不是缺陷),不会自动跑 Literal 校验。

关于"Pydantic 不应该天然校验吗"

简短答:对字段(field)会,对 ClassVar 不会。

细节prompt_position 写法是 ClassVar[tuple[Literal[...], Literal[...]]] = (...) —— ClassVarPEP 526 定义的"类级标量"标记,Pydantic 看到 ClassVar 就不会把它登记成 field,自然也不跑 model_validator。要在运行时硬卡住,得用 __init_subclass__(10 行内)。

如果改成普通字段(去掉 ClassVar)则 Pydantic 会校验 —— 但代价是每个工具实例都会带一份这个 tuple 数据,且子类无法用 ClassVar = ... 那种"类级常量"语义表达;这与现有 name / description / params_schema 等所有"工具元数据用 ClassVar"的现有约定背道而驰。所以选 ClassVar + __init_subclass__ 校验。

init_subclass 校验代码

# base.py 中 __init_subclass__ 内追加(约 15 行)

# 与 §3.1 ChatLLMPromptPosition / DeskLLMPromptPosition Literal 严格同步
# v6 起:用户意图位 + 历史快照位 + 非 list[BasePrompt] 字段全部排除
_VALID_CHAT_POSITIONS = frozenset({
    "system_msg_prompt", "before_input_msg_prompt",
    "after_input_msg_prompt", "after_intermediate_msg_prompt",
    # 排除:reformat_input_prompt(用户意图位)/ conversation_msgs / intermediate_msgs(非 list[BasePrompt])
})
_VALID_DESK_POSITIONS = frozenset({
    "system_prompt", "instruction_prompt",
    "intermediate_prompt", "current_desk_screenshot_prompt",
    # 排除:original_desk_screenshot_prompt(历史快照)/ purpose_prompt(用户意图位)/ prefix_prompt(用户意图位)
})

@classmethod
def _validate_prompt_position(cls) -> None:
    overrides_render = cls._format_2_prompt is not BaseTool._format_2_prompt
    if not overrides_render:
        # 未覆写 _format_2_prompt 的工具不需要 prompt_position
        if cls.prompt_position is not None:
            raise TypeError(
                f"{cls.__name__} 设置了 prompt_position 但未覆写 _format_2_prompt(),无意义"
            )
        return
    if cls.prompt_position is None:
        raise TypeError(
            f"{cls.__name__} 覆写了 _format_2_prompt() 但未声明 prompt_position;"
            f"必须填 (chat_pos | None, desk_pos | None),至少一边非 None"
        )
    chat_pos, desk_pos = cls.prompt_position
    if chat_pos is None and desk_pos is None:
        raise ValueError(
            f"{cls.__name__}.prompt_position = (None, None) 与 prompt_position = None 语义重复。"
            f"如不打算注入,请去掉 _format_2_prompt 覆写并把 prompt_position 设回 None"
        )
    if chat_pos is not None and chat_pos not in _VALID_CHAT_POSITIONS:
        raise ValueError(f"{cls.__name__}.prompt_position[0]={chat_pos!r} 非法 ChatLLM 段位")
    if desk_pos is not None and desk_pos not in _VALID_DESK_POSITIONS:
        raise ValueError(f"{cls.__name__}.prompt_position[1]={desk_pos!r} 非法 DeskLLM 段位")

注意:判定"是否覆写"用 cls._format_2_prompt is not BaseTool._format_2_prompt,而不是 cls.format_2_prompt。因为 format_2_prompt 是父类框架方法、不应该被覆写;用户的覆写信号在 _format_2_prompt

3.6 范例:TFNotes 工具骨架

简单情况 —— _format_2_prompt 返回 str(最常见)

# tfrobot/drive/tool/tf_notes/tool.py
from typing import ClassVar
from pydantic import TypeAdapter, BaseModel
from tfrobot.drive.tool.base import BaseTool
from tfrobot.schema.brain.chain.prompt.context_vars import PromptContext


class _AddNoteParams(BaseModel):
    note: str


class TFNotesTool(BaseTool):
    name: ClassVar[str] = "tf_notes"
    description: ClassVar[str] = "记录工作笔记,自动注入到下一轮 LLM 决策的 prompt"
    params_schema: ClassVar = TypeAdapter(_AddNoteParams)

    # ★ 新契约:ChatLLM 下注入到 system_msg_prompt(角色/记忆位),
    # DeskLLM 下推荐 system_prompt(同语义)。
    # 注:v6 起 purpose_prompt / prefix_prompt 被铁律 1 排除(用户意图位);
    #    original_desk_screenshot_prompt 被铁律 2 排除(历史快照),所以
    #    DeskLLM 这边没有"任务目的"型注入位可选 —— 笔记更适合放 system_prompt。
    prompt_position: ClassVar = ("system_msg_prompt", "system_prompt")

    # 状态存储——工具自己说了算
    _notes: list[str] = []  # 简化示意;生产可换 file/DB/conv-scoped dict

    def _run(self, *, note: str) -> str:
        self._notes.append(note)
        return f"已记录笔记 #{len(self._notes)}"

    async def _async_run(self, *, note: str) -> str:
        return self._run(note=note)

    # 子类只需返回 str,父类 format_2_prompt 自动:
    #   1. 转义字面花括号
    #   2. 包入 NormalizePrompt(FStrPromptTemplate(templates={DEFAULT: <escaped str>}))
    # 工具 dev 写正常 Python str 即可,无需懂 fstring placeholder 规则
    def _format_2_prompt(self, prompt_ctx: PromptContext) -> str | None:
        if not self._notes:
            return None
        body = "\n".join(f"- {n}" for n in self._notes)
        # 即使笔记里出现 "代码 {x: 1}" 这类字面花括号,框架兜底会自动 {{ }} 转义
        return f"# 工作笔记(截至当前轮)\n\n{body}"

高级情况 —— _format_2_prompt 返回 BasePrompt(i18n / 自定义模板)

需要按 locale 渲染或自定义模板时,可直接返回 NormalizePrompt(享受全 ctx 字段)或自家其它 BasePrompt 子类:

def _format_2_prompt(self, prompt_ctx: PromptContext) -> "BasePrompt | None":
    if not self._notes:
        return None
    notes_text = "\n".join(f"- {n}" for n in self._notes)
    # 直接用 NormalizePrompt:写模板时拿全 ctx 作为 vars
    # 注意:开发者直接写模板时,字面花括号要自己双倍写(与 AdditionalInfoPrompt 等同规则)
    return NormalizePrompt(
        template=FStrPromptTemplate(
            templates={
                Locale.EN: f"# Working notes (user={{user_input[input]}})\n\n{notes_text}",
                Locale.ZH: f"# 工作笔记(用户={{user_input[input]}}\n\n{notes_text}",
            },
            active_language=Locale.DEFAULT,
        ),
    )

单边注入示例 —— 仅 ChatLLM 下注入

某些工具的注入语义只对 ChatLLM 有意义(如笔记类、对话偏好类),DeskLLM(任务执行环境)下注入笔记反而干扰:

class ChatOnlyMemoTool(BaseTool):
    name: ClassVar[str] = "chat_memo"
    description: ClassVar[str] = "对话备忘,仅在 ChatLLM 下注入"
    # ★ 单边注入:DeskLLM 下显式沉默
    prompt_position: ClassVar = ("system_msg_prompt", None)

    def _format_2_prompt(self, ctx: PromptContext) -> str | None:
        return self._memo or None

ChatOnlyMemoTool 实例若同时被注册到 ChatLLM Robot 与 DeskLLM Robot,前者会注入到 system_msg_prompt,后者甚至不调用 _format_2_prompt(注入器在 §3.3 第二层短路就跳过)。

LLM 调用时 after_input_msg_prompt 段最终内容(ChatLLM 情景)

[原 after_input_msg_prompt 渲染结果]

# 工作笔记(截至当前轮)

- 待办:先抽取目录
- 用户更关心方法论而非数字

四、变更清单

v7:原 C3(PromptContext 双视图)已删除(不需要中转字段);C4 签名变为 _collect_dynamic_prompts(prompt_ctx, tools, position_index);C5/C6 改为消费"装配方法局部 dict"。

# 文件 改动 性质 状态
C1 tfrobot/drive/tool/base.py 新增 ChatLLMPromptPosition / DeskLLMPromptPosition Literal、prompt_position ClassVar、父类框架方法 format_2_prompt() + 子类业务点 _format_2_prompt()__init_subclass___validate_prompt_position() ✓ S1 已落地
C2 tfrobot/brain/chain/prompt/normalize_prompt.py 新建 NormalizePrompt(BasePrompt):BasePrompt 体系最低限度具体化(持有 template,全 ctx 注入),约 50 行(参见 §3.1.5) ✓ S1 已落地
~~C3~~ ~~tfrobot/schema/brain/chain/prompt/context_vars.py~~ ~~PromptContext 加 _active_tool_instances~~ ✗ v7 撤回(§3.4)
C4 tfrobot/brain/chain/llms/base.py 新增 _collect_dynamic_prompts(prompt_ctx, tools, position_index: int) -> dict[str, list[BasePrompt]] —— 公共注入器;caller 立位传 0 (chat) / 1 (desk);返回 dict 应作为 caller 局部变量即用即弃 ✓ S2 已落地
C5 tfrobot/brain/chain/llms/chat_llm.py format_to_request_msgstools 入参;进入时 buckets = self._collect_dynamic_prompts(prompt_ctx, tools, 0);4 个 system 段位渲染由 self.X 改为 self.X + buckets.get("X", []) 临时合并;complete() Phase 3 调用处补传 tools ✓ S2 已落地
C6 各 DeskLLM adapter(tfrobot/brain/chain/llms/generation_llms/desk_llm/*.py 各 adapter 在自己的拼装段调 self._collect_dynamic_prompts(prompt_ctx, tools, 1);4 个 v6 合法段位同款合并;S3 spike 后决策:8 adapter 各自接入(无共通装配方法可抽 —— OpenAI Responses instructions / Gemini Part / DeepSeek/Ollama suffix 各自不同) ✓ S3 已落地(8 adapter)
C7 tfrobot/brain/chain/prompt/__init__.py 导出 NormalizePrompt ✓ S1 已落地
C8 tests/unit_tests/drive/tool/test_base_tool_context.py 新建:覆盖 §3.5 三层校验、_format_2_prompt 六态归一化、异常降级、prompt_position 非法值拒绝、字面花括号自动转义 ✓ S1 已落地(17 cases)
C9 tests/unit_tests/brain/chain/llms/test_tool_prompt_injection.py 新建:ChatLLM/DeskLLM 各自一组集成(mock LLM)测试,覆盖临时合并不污染实例字段、tools 入参顺序、空跳过、单边沉默 ✓ S2 ChatLLM 部分已落地(9 cases)+ ✓ S3 DeskLLM 部分已落地(8 cases,含 D1-D8 + 铁律 1/2 import-time 拒)
C10 tests/unit_tests/brain/chain/prompt/test_normalize_prompt.py 新建:NormalizePrompt.format_2_str / format_2_multimodal 单元 + 全 ctx 字段在模板里可引用 + 渲染失败兜底 ✓ S1 已落地(10 cases)
C11 docs/drive/tools/prompt-injection.md 新建:开发者指南(写工具时如何用 _format_2_prompt + 字符串 vs BasePrompt 两条路径选择) S4 落地
C12 tfrobot/schema/exceptions.py PROTOCOLS Literal 新增 tool_format_2_prompt —— BaseTool._format_2_prompt 返回非法类型的协议名 ✓ S1 已落地(C1 副产物)

零侵入承诺验证:现有 30 个 BaseTool 子类(PyIDE 14 / Search 4 / MCP / DSL / Iteration / Recall / ExpandContext / Trafilatura / RetrieveTool / Tool / InvalidTool 等)全部不涉及 prompt_position / _format_2_prompt 改动 —— S1 落地后 git diff --stat 上 0 个工具源文件被触动。


五、合法位选择的取舍

5.1 收口判据(两条铁律 + 一条架构边界)

判据 内容 后果
铁律 1 禁止注入「用户意图表达位」:reformat_input_prompt(替换用户输入)/ purpose_prompt(任务目的)/ prefix_prompt(紧贴 LLM 输出的最末尾行为指令) 工具一旦写到这些位 = 篡改用户原话/任务描述/最后行为指令;bug 时意图被改难以追溯
铁律 2 禁止注入「系统维护的历史快照位」:original_desk_screenshot_prompt(任务初始 desk 状态) 工具改写历史 = 破坏 desk 时间线因果性、丢失 audit 能力
架构边界 list[BasePrompt] 字段:conversation_msgs / intermediate_msgs 是 list[BaseMessage],不在配置态 工具想反映历史/中间结果 → 走标准链路(ToolReturn → intermediate_msgs),不要绕道 prompt 配置态

5.2 各段位最终判定

段位 LLM 收/不收 判据 / 用途
system_msg_prompt ChatLLM 角色、全局背景、长期记忆。最常用注入位
before_input_msg_prompt ChatLLM 用户输入前的引导(消息 role=system)
after_input_msg_prompt ChatLLM 用户输入后的补充现状
after_intermediate_msg_prompt ChatLLM 中间消息后的总结位
reformat_input_prompt ChatLLM 铁律 1:替换用户输入文本,工具污染等于篡改用户原话
conversation_msgs ChatLLM 架构边界:list[BaseMessage],非 list[BasePrompt]
intermediate_msgs ChatLLM 架构边界:同上。工具中间结果走 ToolReturn 链路
system_prompt DeskLLM 系统消息位
instruction_prompt DeskLLM few-shot 区,"经验回放"类工具
intermediate_prompt DeskLLM 中间动作位
current_desk_screenshot_prompt DeskLLM 当前环境实时状态(live 视图,与 original 历史快照对偶)
original_desk_screenshot_prompt DeskLLM 铁律 2:系统维护的初始环境快照,工具改写 = 改写历史
purpose_prompt DeskLLM 铁律 1:任务目的,是用户意图核心
prefix_prompt DeskLLM 铁律 1:紧贴 LLM 输出的最末尾行为指令,位置敏感性最高

ChatLLM 4 个合法位 + DeskLLM 4 个合法位。Literal[...] 在 §3.1 已按此精确收口,inline 注释保留两条铁律的判据,给后来开发者扩展时一个清晰的 bar。

5.3 如果未来要新增 LLM 段位

当 LLM 实现者新增一个 *_prompt: list[BasePrompt] 字段时,本 PRD 的"是否纳入注入位"决策流:

1. 该段是否承载用户原话/任务目的/最末尾行为指令?
   是 → 铁律 1 → 不收
2. 该段是否是系统维护的历史快照?
   是 → 铁律 2 → 不收
3. 该段是否是 list[BasePrompt]?
   否 → 架构边界 → 不收
4. 否则 → 收,加 inline 注释说明语义

六、并发与生命周期

  • 并发format_2_prompt 在 LLM 调用线程中同步调用一次。工具自己负责 thread-safe(如多 conversation 并发、Robot 多副本场景)。框架对 _active_tool_instances 不做锁。
  • 同步/异步:本期只暴露 sync format_2_prompt。理由:
  • 90% 状态读取(内存 / 本地文件)天然 sync。
  • 需要 async IO 的工具应在 _async_run 内 prefetch 到本地状态、render 时纯读。
  • 如未来证伪此假设,再加 async def async_format_2_prompt() —— 默认 delegate 到 sync 是无破坏面的扩展。
  • 失败处理format_2_prompt 抛异常时降级为空字符串 + 记 ERROR 日志,不阻塞 LLM 调用(这是用户体验底线 —— 工具状态注入失败不能让对话挂掉)。

七、可观测性

最小集成:

  • _collect_tool_context_buckets 内打 OTel attribute:
  • tool_context.tools_total:本次调用中声明 prompt_position 的工具数
  • tool_context.tools_rendered:实际产生非空贡献的工具数
  • tool_context.bytes:所有 render 文本累计字节数
  • tool_context.position_<name>.tools:各位贡献者数(仅非零位上报)

不做:单工具 render 内容入 trace(隐私 + 成本敏感)。


八、测试方案

8.1 BaseTool 单元(test_base_tool_context.py

Case 验证
默认 BaseTool 子类不写 prompt_position 且不覆写 _format_2_prompt() → import OK 零侵入
子类覆写 _format_2_prompt() 但未填 prompt_position → import 抛 TypeError L2 校验
子类填了 prompt_position=("WRONG", "system_prompt") → import 抛 ValueError(chat_pos 非法) L2 校验
子类填了 prompt_position=("system_msg_prompt", "purpose_prompt") → import 抛 ValueError(v6 铁律:purpose_prompt 是用户意图位) L2 + 铁律 1
子类填了 prompt_position=("system_msg_prompt", "original_desk_screenshot_prompt") → import 抛 ValueError(v6 铁律:original 是历史快照) L2 + 铁律 2
子类填了 prompt_position=(None, None) → import 抛 ValueError(与 None 重复) L2 校验
子类填了 prompt_position=("system_msg_prompt", None) → import OK;ChatLLM 路径注入,DeskLLM 路径不调 format_2_prompt 单边注入
子类填了 prompt_position=(None, "system_prompt") → import OK;DeskLLM 路径注入,ChatLLM 路径不调 format_2_prompt 单边注入(反向)
子类填了 prompt_position 但没覆写 _format_2_prompt → import 抛 TypeError(多余声明) L2 校验
默认 format_2_prompt() 返回 None(基类未覆写 _format_2_prompt 默认行为
_format_2_prompt 返回 "hello"format_2_prompt 返回 NormalizePrompt(template=FStrPromptTemplate(templates={DEFAULT: "hello"})) str 归一化
_format_2_prompt 返回 "代码 {x: 1}"(含字面花括号)→ 包入 NormalizePrompt 后 format_2_str(ctx) 不抛 KeyError,输出原文 自动转义不变
_format_2_prompt 返回 ""format_2_prompt 返回 None 空跳过
_format_2_prompt 返回 Noneformat_2_prompt 返回 None None 透传
_format_2_prompt 返回 BasePrompt 子类实例format_2_prompt 原样透传 BasePrompt 透传
_format_2_prompt 抛异常 → format_2_prompt 返回 None + ERROR 日志 异常降级
_format_2_prompt 返回非法类型(如 dict)→ 抛 TFProtocolError 类型契约

8.2 LLM 注入集成(test_tool_prompt_injection.py,使用 MockBaseLLM 路径)

Case 验证
单工具 chat / 单工具 desk / 各注入到对应位 G1 双适配
工具 _format_2_prompt 返回 None → 该位 prompt 列表保持原样 G6 空跳过
同位多工具 → 临时合并到 *_prompt 列表后,按 active tools 顺序参与 format_2_str 循环 G5 顺序
工具不在 active tools 列表 → format_2_prompt 不被调用(spy 验证) G6 触发面
_format_2_prompt 抛异常 → 该工具被跳过,其他工具仍正常注入 §六 失败处理
同时存在 system_msg_prompt + after_input_msg_prompt 工具 → 各自落到对应段 桶聚合
关键不变量:注入前后 self.system_msg_prompt / self.purpose_prompt 等实例字段长度不变 配置态不可变
工具返回 BasePrompt(含 format_2_multimodal 的多模态附件)→ 附件正常被 ChatLLM 收集 BasePrompt 全套能力

九、分阶段实施

阶段 Story 范围 验收 状态
P1 TFROB-464 (S1) C1 + C2 + C7 + C8 + C10 + C12(BaseTool 双层 + NormalizePrompt + 协议名 + 两组单元测试) make unit-test 全绿;现有 30 个 BaseTool 子类零改动;NormalizePrompt 全 ctx 字段可在模板里引用 + 字面花括号自动转义不抛 KeyError;NormalizePrompt 单元覆盖率 ≥ 90% ✓ 已落地(NormalizePrompt 100% cov,27 cases 全过)
P2 TFROB-465 (S2) C4 + C5 + C9 ChatLLM 部分(公共注入器 + ChatLLM 4 段位接入 + 集成测试) ChatLLM 路径完整可用;临时合并不污染 self.system_msg_prompt 等实例字段 ✓ 已落地(9 cases;adapter 0 改动;make lint 全过)
P3 TFROB-466 (S3) C6 + C9 DeskLLM 部分(spike 接入点决策 + 各 adapter 4 段位接入 + 集成测试) DeskLLM 路径完整可用;接入点先小调研再实施(§十 风险 §3) ✓ 已落地(spike → 8 adapter 各自接入;8 cases;make unit-test 5087 全过;make lint 0 告警)
P4 TFROB-467 (S4) C11 + 第一个生产工具落地(TFNotes 或等价物) 端到端联调(跨 user input 笔记可见);开发者指南文档 待落地

十、风险与未决

10.1 已识别风险

风险 缓解
注入器在 format_to_request_msgs 中加重 method 复杂度 把注入器抽成 _collect_tool_prompt_buckets 单方法(C4),主流程只多一次 dict 查与 list 拼接
format_2_prompt 静默捕获异常可能掩盖工具 bug 必须打 ERROR 日志 + OTel exception event;不上抛仅在"prompt 段位拼装"阶段
_active_tool_instances 字段被外部 Prompt 误读 exclude=True + 文档约定;不公开访问
ChatLLM 子类(OpenAI / Anthropic / DeepSeek 等)有自定义 _assemble_api_request 注入应在 format_to_request_msgs 阶段(公共上游),不影响下游 reformat。已实测 chat_llm.py 的 7 段式装配是基类逻辑,子类只 override _assemble_api_request 与 reformat —— 选址正确
_format_2_prompt 返回非 str / BasePrompt / None 异常类型 父类 format_2_prompt 在归一化阶段抛 TFProtocolError(protocol="tool_format_2_prompt"),与现有 tool_return_schema 同形
工具返回的 BasePrompt 子类用了 FStrPromptTemplate 但 placeholder 与 format_to_request_msgs 现场 ctx 不匹配 这是工具自家模板设计问题,框架层不兜底;C11 开发者指南显式提示"返回 BasePrompt 时由你保证模板可渲染"。

10.2 待审议(请用户拍板)

v7:原第 4 条(_active_tool_instances 命名)随 §3.4 撤回而失效;第 1/2/7 条 S1 落地时按倾向选项实施。

  1. ~~NormalizePrompt 命名~~ ✓ 已采纳:沿用 NormalizePrompt,与 AdditionalInfoPrompt / MemoPrompt 形成"语义化裁剪 ↔ 不裁剪"的对称。
  2. ~~是否在 NormalizePrompt保留 always_render 字段~~ ✓ 已采纳:不保留;try/except 兜底返回 "" 足够。
  3. TFNotes 是否作为本 PRD 配套样板 一并落地,还是单独起 ticket?已采纳:拆 TFROB-467 (S4) 同 Epic 子 Story。
  4. ~~_active_tool_instances 命名~~ ✗ v7 撤回此设计(§3.4),整个字段不存在。
  5. 是否要把 system_msg_promptafter_input_msg_prompt 等合法位字面量集中到 tfrobot/schema/brain/chain/prompt/positions.py 作为单一权威源(避免 BaseToolChatLLM 双方各写一份)。倾向集中到 schema 模块下;S2/S3 落地时若发现 ChatLLM/DeskLLM 段位字面量需要外露给注入逻辑用,再做集中。
  6. DeskLLM 7 段位的注入接入点 DeskLLM 不像 ChatLLM 有统一 format_to_request_msgs,各 adapter 拼装路径分散 —— S3 第一步 spike 决定是否先抽 DeskLLM 共通拼装方法再注入。视调研结果决定 P3 工作量。
  7. ~~父类框架方法的命名~~ ✓ 已采纳:format_2_prompt + 子类 _format_2_prompt,与 run + _run 同构。
  8. dynamic_prompts 第二个 provider 是否真会出现(比如 Memory 想动态贴长期记忆段)?如真出现,把"BaseLLM 局部 dict"演化为更显式的 ProviderRegistry 集合再合并;本期不抽。

十一、附录

A. 与 Intermediate Trace 的关系

intermediate_trace 解决"跨 Chain 把执行过程结构化展开给 LLM 看"的问题,注入位是 conversation 与 intermediate_msgs 之间的动态展开。本 PRD 解决"跨 LLM 调用让工具主动注入自己的 world view"的问题,注入位是 system-role prompt 段位。两者正交:trace 是消息流维度,本 PRD 是 prompt 配置态维度。

B. 与 runtime_vars 的关系

runtime_vars 是单次 LLM 调用内的 key-value 槽,生命周期 = 单次 complete()。本 PRD 给的是工具自治状态 → prompt 注入的标准通路;工具如果想用 runtime_vars 作为本次注入的中转可以,但跨调用持久化是工具自己的事,不是 runtime_vars 的事。

C. 与 ChainCtx 的关系

ChainCtx 是 Chain 状态机内的运行时上下文,与 LLM prompt 拼装位完全无关。本 PRD 不依赖、不修改、不撤销 ChainCtx。已有把状态塞 ChainCtx 的工具如果想迁移到新通路,是一次自愿重构,不强制