Skip to content

TFSAction 协议设计规范

设计背景与目标

在TFRobotServer中,所有的RobotComponent均是通过配置生成,而在发布后的Robot工作过程中,数据会不断积累,通过低代码配置生成组件虽然方便,但有以下几个问题:

  1. 调试困难。因为其多数功能并非代码写死,而是依赖于低代码配置,故而多数时候,组件无法进行方便地调试。

    举例来讲,比如某个Prompt组件,因为其模板是配置写入的,因此在配置修改后,如果校验这个写入的配置工作是否正常?有时候静态的请求检查器是无法100%确定的,因此最好有一个Render功能可以进行动态调试。 2. 数据查询困难。因为Robot运行并非是无状态的,而且随着运行会积累越来越多的数据,比如说Memory中的DPE文件数据。而如何动态地完成数据查询呢?不同的组件,其内部过程数据结构不一,而每个组件又都是动态渲染的,因此其查询接口也必须支持动态渲染。

    举例来讲,比如Memory组件,其内部数据虽然存储于PG数据库,但数据结构繁多,且每个组件是否启用,当前配置均不确定,而且随时可能修改,因此如果将前端界面与后端进行紧耦合的关联会导致非常大的维护成本(任何一点后端数据结构的变更,均有可能影响前台表现)。

基于以上两点问题,TFRobotServer尝试对TFRobot的 TFAction 模块进行二次强化,推出了 TFSAction 的封装。 TFSAction模块主要有以下几个部分组成:

  1. ActionProtocol。用于定义一次性的操作,这些操作主要聚焦于调试组件或者动态修改组件配置。不同的Action之间没有数据上下文的关联性,独立性强,可以单独渲染与使用。
  2. DataProtocol。用于定义数据查询操作,往往由一组Action组成,这些Action之间有数据上下文的关联性,联合性强,而且必须一起使用。

    DataProtocol又分为两个部分: 1. PageableProtocol。用于定义分页查询操作。 2. GraphicalProtocol。用于定义图形化数据查询操作。

TFS Action 协议核心说明

1. x-namespace、x-actions 与 x-ref 机制

x-namespace 说明

  • 作用:用于在 Action 的返回 Schema(RetSchema)中,为其它 Action 提供变量注入的命名空间。
  • 用法:可在任意对象(推荐 items/nodes/edges 等关键结构)下添加 x-namespace 字段。
  • 结构:
  • x-namespace 是一个字典,key 为注入的变量名,value 为 Jmespath 路径(相对于当前对象),或 Options 结构。
  • 所有 Action 共享同一 namespace,变量名保持一致即可。
  • 示例:
{
  "type": "object",
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {"id": {"type": "string"}},
        "x-namespace": {
          "row_id": "id",
          "user_options": {
            "Options": {
              "labels": "values[*].display_name",
              "keys": "values[*].user_id",
              "values": "users"
            }
          }
        },
        "x-actions": ["update_row", "delete_row", "select_user"]
      }
    }
  }
}
  • 说明:所有 Action 通过 x-ref/x-default 直接引用 namespace 变量名,无需区分 Action 名称。

x-actions 说明

  • 作用:用于在 RetSchema 的关键结构(如 items/nodes/edges 的元素)下声明当前数据项可用的 Action 名称集合,便于前端动态渲染可操作按钮。
  • 用法:x-actions 是一个字符串数组,内容为可用的 Action 名称。
  • 示例:
{
  "type": "object",
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {"id": {"type": "string"}},
        "x-namespace": {
          "row_id": "id",
          "user_options": {
            "Options": {
              "labels": "values[*].display_name",
              "keys": "values[*].user_id",
              "values": "users"
            }
          }
        },
        "x-actions": ["update_row", "delete_row", "select_user"]
      }
    }
  }
}

x-ref 说明

  • 作用:用于在 Action 的参数 Schema(ParamsSchema)中,声明本字段依赖于其它 Action 的 namespace 变量。
  • 用法:在参数字段添加 x-ref,key 为变量名,value 为 namespace 中的变量名。
  • 示例:
{
  "type": "object",
  "properties": {
    "row_id": {
      "type": "string",
      "x-ref": "row_id"
    }
  }
}

x-default 说明

  • 作用:在 ParamsSchema 的字段中声明该字段的默认值应从当前 namespace 中通过 Jmespath 路径动态获取。
  • 用法:在参数字段添加 x-default,值为 Jmespath 路径(相对于当前 namespace)。
  • 示例:
{
  "type": "object",
  "properties": {
    "user_id": {
      "type": "string",
      "x-default": "user_options.keys[0]"
    }
  }
}
  • 解释:如上,user_id 字段的默认值会自动从当前 namespace 的 user_options.keys[0] 获取。

x-fetch 说明

  • 作用:用于在 Action 的参数 Schema(ParamsSchema)中,声明当 x-ref / x-default 无法从当前 namespace 解析出值时,前端可以通过触发一个“补全动作(Action)”来补充 namespace,从而使后续 x-ref / x-default 能够继续解析。
  • 用法:在参数字段添加 x-fetch,值为动作名称字符串(必须是当前 Setting 已注册的 TFSAction)。
  • 约束:x-fetch 的目标 Action 必须是“读操作”(建议仅允许 resource_op_typeget/get_one/get_list/get_graph 的 Action;由各语言实现自行约束)。
  • 示例:
{
  "type": "object",
  "properties": {
    "user_id": {
      "type": "string",
      "x-ref": "userId",
      "x-default": "userId",
      "x-fetch": "aget_current_user"
    }
  }
}

x-fetch 触发规则(前端必须遵循)

  • 规则 1(优先级):前端在组装参数时,必须优先尝试 x-ref / x-default
  • 规则 2(触发条件):仅当 x-ref / x-default 均无法从当前 namespace 得到可用值时,才允许触发 x-fetch
  • 规则 3(补全后重试)x-fetch 执行完成后,前端必须重新尝试 x-ref / x-default

x-fetch 缓存/去重要求(前端必须实现)

  • 前端必须按 (action_name + resolved_params) 维度对 x-fetch 做缓存/去重,避免同一表单/同一轮渲染中重复触发相同补全动作。
  • 说明:这里的“缓存/去重”是协议要求的前端行为,用于避免请求风暴;不代表后端需要提供额外缓存能力。

x-fetch 循环/风暴控制(前端必须实现)

  • 前端必须对同一次参数解析链路中的 x-fetch 触发次数进行限制。
  • 阈值:最大触发次数为 10
  • 当触发次数超过阈值时,前端应中止自动补全,并给出明确告警/提示(如“自动补全触发次数过多,已中止,请手动补全参数或检查依赖关系”)。

2. Jmespath 在 List/数组场景下的用法

  • Jmespath 支持 * 星号:如 items[*].id 取所有 id。
  • 推荐做法:前端渲染每个 item 时,将当前 item 的 namespace 注入变量空间,x-namespace 中 Jmespath 用相对路径(如 "id"、"person.name")。
  • 如果需要引用“对应 index”,建议在前端遍历时自动注入当前 item 的 namespace,避免复杂嵌套。
  • FAQ:
  • Q:能否只在根节点声明所有 x-namespace?
    • A:不推荐。建议在 items/nodes/edges 等关键结构层级声明,便于每个数据项独立注入上下文。
  • Q:如何表达 list 中“对应 index”?
    • A:前端遍历时自动注入当前项的 namespace,x-ref 直接写变量名即可。

3. 协议实现建议

  • 保持 x-namespace 注入的粒度与前端渲染粒度一致。
  • x-actions 明确声明每个数据项可用的 Action,便于前端渲染与权限控制。
  • x-ref 只做变量名引用,前端自动匹配当前上下文。
  • x-default 支持字段默认值从 namespace 动态获取。
  • x-fetch 用于在 x-ref/x-default 无法解析时补全 namespace;其值为动作名称字符串。
  • 普通 Action 无需 x-namespace/x-actions/x-ref。

4. x-namespace 快速上手(高级工程师指南)

本节目标:让你用 10 分钟理解并能独立为一个新的 Action 组(get_list / get_graph / update / delete 等)补齐 x-namespace / x-actions / x-ref / x-default 元数据。

4.1 心智模型:三件事 + 三个位置

TFSAction 的联动效果,本质上是三件事相互配合:

  • 返回数据“声明可操作性”:在返回对象(通常是 items/nodes/edges 的元素)声明 x-actions
  • 返回数据“注入上下文变量”:在同一层级声明 x-namespace,把当前元素的字段映射为 namespace 变量。
  • 后续 Action “引用上下文变量”:在后续 Action 的参数 schema 上,通过 x-ref / x-default 从当前 namespace 取值。

对应到代码落点(非常关键):

  • DTO(返回值模型):用 model_config.json_schema_extra 声明 x-namespace / x-actions
  • Action 方法参数:用 Annotated[..., Field(json_schema_extra={...})] 声明 x-ref / x-default
  • Action 注册(@tfs_action / ActionAlias):决定这是 pageable 还是 graphical,以及资源/操作类型(get_list/get_graph/update/delete/...)。

4.2 命名约定(推荐)

为了让前端/协议使用者“看变量名就知道来源”,建议 namespace 变量遵循:

  • 列表项变量:以资源名为前缀
  • docId / fileUri / fileType
  • clsIri / clsName
  • 图元素变量:以元素类型为前缀
  • Node: nodeIri / nodeLabel / nodeCls / nodeProperties
  • Edge: edgeSource / edgeTarget / edgeProperty

注意:namespace 是全局共享的变量空间(同一次 DataProtocol 链路里),因此:

  • 同一资源尽量复用一致命名
  • 不同资源避免撞名(除非你确定语义一致)

4.3 层级字段(A.B.C.xxx)如何注入与引用

x-namespace 的 value 本质是 JMESPath 表达式(相对于“当前对象”)。

因此当你的数据结构有层级(例如 A.B.C.xxx),你有两种推荐写法:

写法 A:直接把叶子字段映射成扁平变量(最常用)

例如返回对象形如:

  • profile.name
  • profile.address.city

则可以直接:

{
  "x-namespace": {
    "userName": "profile.name",
    "userCity": "profile.address.city"
  }
}

后续 Action 入参只需要引用变量名:

{
  "x-ref": "userName"
}

写法 B:先注入一个“子对象”,再在 x-default 里继续向下取(适合表单默认值)

例如:

{
  "x-namespace": {
    "profile": "profile"
  }
}

后续入参默认值可以写:

{
  "x-default": "profile.address.city"
}

说明:

  • x-default 的路径是 相对于 namespace 的路径
  • 如果你把 profile 整体注入进 namespace,那么 profile.address.city 就自然可用。

4.4 items/nodes/edges 是数组时:会重复吗?会覆盖吗?

结论先说清楚:

  • Schema 不会重复x-namespace/x-actions 是“schema 元数据”,定义一次即可,不会因为 items 是数组而在 schema 层面重复 N 份。
  • 运行时是“逐元素作用域”:前端渲染 items 时,会为 每个元素 创建“当前元素的 namespace 上下文”。
  • 不会互相污染:第 1 个 item 注入的 docId 不会影响第 2 个 item 的 docId,因为它们在不同的“元素上下文”里。

更具体地说(推荐理解方式):

规则 1:namespace 的“注入粒度”应与 UI 遍历粒度一致

  • 列表:每行(items[*] 的元素)各自一份上下文
  • 图:每个 node、每个 edge 各自一份上下文

规则 2:同名变量是允许的,但作用域由“当前元素”决定

例如 AIMemoryDocx-namespace 注入 docId

  • 渲染 doc 列表时,每行都会有一个 docId,但这是“该行自己的 docId”。
  • 用户点击第 3 行的 aupdate_doc 按钮,前端就用第 3 行上下文里的 docId 填参。

规则 3:如果你同时有“容器级”和“元素级”注入,常见合并策略是“元素级覆盖容器级”

例如 PageableList 容器可能注入:

  • page
  • pageSize

items 元素注入:

  • docId
  • keywords

那么一个合理的使用方式是:

  • 容器级变量(如 page/pageSize)用于翻页等全局动作
  • 元素级变量(如 docId)用于行级 update/delete

规则 4:不要在“父层级”用 items[*].xxx 把整列注入成数组,除非你明确需要“批量操作”

例如你可以在父层级注入:

{
  "x-namespace": {
    "allDocIds": "items[*].docId"
  }
}

但这会得到一个“所有 docId 的数组”,适用于批量操作; 对于“行级操作”,依旧推荐把 x-namespace 放在 item 元素上,使用相对路径(如 "docId": "docId""docId": "doc_id")。


5. PageableProtocol 实战:aget_docs → aupdate_doc / adelete_doc

这里用现有的 Memory 文档列表作为例子:

  • aget_docs 返回分页列表 PageableList[AIMemoryDoc]
  • 每个 AIMemoryDoc 在 schema 上声明 x-actions,前端渲染每行按钮
  • aupdate_doc/adelete_doc 的入参通过 x-ref/x-default 自动从当前行 namespace 取值

5.1 返回值(DTO)侧:注入 x-namespace + 声明 x-actions

位置:tfrobotserver/dtos/factory/brain/memory/ai_memory.py(示例取自 AIMemoryDoc)

核心要点:在“列表元素”层级声明(即 items 的元素类型上),不要放在列表容器上。

class AIMemoryDoc(HttpModel):
    # ... fields ...

    model_config: ClassVar[ConfigDict] = ConfigDict(
        json_schema_extra={
            "x-namespace": {
                "docId": "docId",
                "fileUri": "fileUri",
                "fileType": "fileType",
                "keywords": "keywords",
                "docMetadata": "docMetadata",
            },
            "x-actions": ["aupdate_doc", "adelete_doc"],
        }
    )

含义:前端渲染每个 doc 行时,会把当前行的 docId/fileUri/... 注入到 namespace。

5.2 参数(Action)侧:用 x-ref / x-default 引用 namespace

位置:tfrobotserver/robot/factory/brain/memory/dpe_manage_mixin.py

async def aupdate_doc(
    self,
    async_session: AsyncSession,
    doc_id: Annotated[int, Field(json_schema_extra={"x-ref": "docId"})],
    keywords: Annotated[list[str], Field(json_schema_extra={"x-default": "keywords"})],
    metadata: Annotated[dict, Field(json_schema_extra={"x-default": "docMetadata"})],
) -> TDoc:
    ...

解释:

  • x-ref: docId
  • 表示该参数值来自 namespace 的 docId
  • x-default: keywords/docMetadata
  • 表示打开表单时的默认值来自当前行的 keywords/docMetadata

6. GraphicalProtocol 实战:get_graph(nodes/edges) → update/delete

目标:在 get_graph 的返回值中,让前端能够对 节点与边 进行行级(元素级)更新/删除,并且 update/delete 入参可以自动引用当前元素上下文。

6.1 返回值(DTO)侧:Node / Edge 的 x-namespace + x-actions

位置:tfrobotserver/dtos/factory/base.py

Node 示例:

class Node(HttpModel):
    iri: str
    label: str
    cls: str | list[str]
    properties: Optional[dict[str, Any]]

    model_config: ClassVar[ConfigDict] = ConfigDict(
        json_schema_extra={
            "x-namespace": {
                "nodeIri": "iri",
                "nodeLabel": "label",
                "nodeCls": "cls",
                "nodeProperties": "properties",
            },
            "x-actions": ["aupdate_node", "adelete_node"],
        }
    )

Edge 示例:

class Edge(HttpModel):
    source: str
    target: str
    label: str
    property: str
    properties: Optional[dict[str, Any]]

    model_config: ClassVar[ConfigDict] = ConfigDict(
        json_schema_extra={
            "x-namespace": {
                "edgeSource": "source",
                "edgeTarget": "target",
                "edgeLabel": "label",
                "edgeProperty": "property",
                "edgeProperties": "properties",
            },
            "x-actions": ["aupdate_edge", "adelete_edge"],
        }
    )

说明:

  • 前端渲染图时,会分别遍历 nodesedges
  • 每个 node/edge 都会形成“当前元素”的 namespace,上述变量会被注入。

6.2 参数(Action)侧:update/delete 使用 x-ref/x-default

位置:tfrobotserver/robot/factory/brain/memory/graph_manage_mixin.py

Node update 示例:

async def aupdate_node(
    self,
    async_session: AsyncSession,
    iri: Annotated[str, Field(json_schema_extra={"x-ref": "nodeIri"})],
    label: Optional[Annotated[str, Field(json_schema_extra={"x-default": "nodeLabel", "x-ref": "nodeLabel"})]] = None,
    properties: Optional[Annotated[dict[str, Any], Field(json_schema_extra={"x-default": "nodeProperties", "x-ref": "nodeProperties"})]] = None,
) -> None:
    ...

Edge delete 示例:

async def adelete_edge(
    self,
    async_session: AsyncSession,
    source: Annotated[str, Field(json_schema_extra={"x-ref": "edgeSource"})],
    target: Annotated[str, Field(json_schema_extra={"x-ref": "edgeTarget"})],
    property: Annotated[str, Field(json_schema_extra={"x-ref": "edgeProperty"})],
) -> None:
    ...

关键点:

  • x-ref 只能放在“方法参数”的 schema 上,不要放在 DTO 字段上。
  • x-default 的路径是“相对于 namespace 的路径”。如果你把默认值直接映射为变量(如 nodeLabel),那 x-default: nodeLabel 就是最简单写法。

7. 常见坑与排查方式

7.1 把 x-namespace 放错层级

  • 错误:把 x-namespace 放在 PageableListGraphical 容器上
  • 结果:前端无法在“每个 item/node/edge”维度拿到独立上下文
  • 正确:放在 items 的元素 DTO(如 AIMemoryDoc),或 nodes/edges 的元素 DTO(如 Node/Edge

7.2 误用 x-ref

  • 错误:在 DTO 字段上加 x-ref
  • 正确x-ref 是“参数引用机制”,只用于 ParamsSchema(方法参数)

7.3 namespace 变量撞名

namespace 是全局空间:

  • 如果你在 node 与 doc 都用 id,会导致上下文混乱
  • 推荐使用 docId/nodeIri/edgeSource 这种带前缀命名

7.4 如何快速确认前端能否拿到变量

最直接的方式:看 Pydantic JSON Schema 是否包含 x-namespace/x-actions


8. 推荐测试用例模板(验证协议元数据可用)

项目里已有一个参考的范式:

  • tests/unit_tests/dtos/factory/brain/memory/test_rerank_unified_memory.py
  • model_json_schema() 断言 x-namespace/x-actions 存在且内容正确
  • tests/unit_tests/robot/factory/brain/memory/test_unified_memory_actions.py
  • __tfs_actions__ 断言 action 是否注册、alias(category/resource/op) 是否正确

8.1 DTO Schema 测试(推荐)

def test_node_json_schema_extra():
    schema = Node.model_json_schema()
    assert "x-namespace" in schema
    assert schema["x-actions"] == ["aupdate_node", "adelete_node"]

8.2 Action 注册测试(推荐)

def test_action_registration():
    assert "aget_nodes" in RobotUnifiedMemorySetting.__tfs_actions__

说明:

  • 这类测试验证的是“协议元数据是否被正确声明与暴露”,属于后端可测试的部分
  • 真正的“前端运行时注入”发生在 UI 渲染层(遍历 items/nodes/edges 时注入当前元素 namespace),后端无需、也不适合在单测里模拟 UI 行为。

4. 为什么要有 category 字段(以及它如何决定基础返回格式)

在 TFSAction 中,category 的核心目的不是“简单分组”,而是让前端/协议层能够在拿到 Action 列表时,就提前确定该 Action 所遵循的基础数据协议(基础返回格式)

原因在于:TFSAction 覆盖了两类差异很大的数据查询场景(对应不同的 DataProtocol):

  • pageable:分页列表场景。
  • 基础返回格式:必须是“列表容器”,最小包含 items;并按分页模式可选 page/pageSizecursor/pageSize
  • 典型渲染:表格/列表 + 分页器。
  • graphical:图形化查询场景。
  • 基础返回格式:通常是 nodes/edges(或其它图结构容器),用于图可视化渲染。
  • 典型渲染:关系图、知识图谱、流程图等。

如果没有 category:前端只能在“调用后”通过返回值结构去猜测渲染方式,这会导致:

  • 渲染策略不确定:同一个 action-list UI 无法稳定选择列表页/图页等基础组件。
  • 协议演进成本高:任何返回结构的扩展都可能让前端的“猜测逻辑”变复杂。
  • 联动能力受限:像 pageable 场景需要 x-namespace/x-actions/x-ref 形成表格行级联动,必须先知道“这是列表语义”。

因此,category 本质上是“协议选择器”:它决定了该 Action 的基础返回格式应该长什么样,从而让前端在渲染之前就能选对基础组件。

以 get_list 为例:category=pageable 如何约束返回格式

  1. 在协议文档层(PageableProtocol)
  2. docs/protocol/tfs_action/pageable_protocol.md 明确规定:所有列表相关 Action 通过 @tfs_action 注册,category"pageable"
  3. 并且 get_list 的返回结构必须为列表容器:至少包含 items,分页信息按 Page/Cursor 模式附带对应字段。

  4. 在代码层(tfs_action.py)

  5. tfrobotserver/robot/factory/tfs_action.pyActionAlias 定义中,category 仅允许 "pageable" | "graphical"
  6. 同时 ActionAlias 还提供了 resource_op_type,用于标识资源操作类型;当 resource_op_type == "get_list" 时,会触发返回类型校验:
    • tfs_action(...) 在注册 action 时,如果发现 resource_op_type == "get_list",会调用 _validate_get_list_return_type(...)
    • _validate_get_list_return_type(...) 会强制要求 get_list 的函数返回类型(含泛型替换后)必须继承 BasePageableList(例如 PageableList[...] / CursorPageableList[...])。

换句话说:

  • category=pageable 在“协议语义层”告诉前端:这是分页列表,按列表容器渲染。
  • resource_op_type=get_list 在“后端注册层”进一步对 get_list 的返回类型做强约束,确保它真的符合 pageable 协议所要求的基础返回格式。

详细用法见各协议子文档。