跳转至

Agent App 模型与发布边界

本阶段承接 Tenant 闭环,定义租户级 Agent App 的稳定身份、版本化执行配置、 发布与回滚语义,以及供 Agent Factory 消费的一次执行快照。实现跟踪见 Issue #17

目标与边界

Agent App 是租户创建、发布和路由 Agent 的控制面对象。它必须同时解决两类问题:

  • 为 Admin API、Channel Binding 和审计提供稳定的应用身份。
  • 为 Worker 和 Agent Factory 提供不可变、可复现的执行配置。

因此模型不能把名称、草稿、当前配置和历史版本混成一个可覆盖的 JSON 对象。本阶段采用 “稳定根实体 + 版本化 Revision”结构:agent_app 保存身份、生命周期和当前发布指针, agent_app_revision 保存草稿或已发布的执行定义。

本阶段覆盖领域模型、发布/回滚、InMemory Repository 和执行快照契约;不实现 Gateway、 Worker、Admin HTTP API、具体模型客户端、Secret Manager、SQL migration 或跨节点缓存。

核心决策

稳定身份与发布内容分离

app_idapp_key 创建后不可修改。展示名和描述可以修改,但不参与路由、鉴权、 Session 命名空间或缓存键。

App 根实体使用乐观锁 version;Revision 使用 App 内单调递增的 revision

  • app.version 检测元数据、状态和当前发布指针的并发修改。
  • revision 标识可以被历史执行、审计和回滚稳定引用的 Agent 定义。

发布后的 Revision 永久不可修改。更新配置必须维护草稿,再发布为新的 Revision。回滚不覆盖 内容,只把 current_revision 切换到一个历史已发布 Revision。

第一阶段只执行 LLMAgent

模型保留 agent_kindschema_version,但第一阶段只接受 agent_kind = 'llm'。 Graph、Chain、Parallel 和 Cycle 的结构与拓扑校验应在各自设计完成后显式扩展,不能先接受 任意 kind 或把未验证配置直接交给框架。

引用能力,不持有运行时对象

Revision 保存 model_profile_id、工具授权和后续 Knowledge/Backend 引用,不保存 model.Modeltool.Tool、数据库连接、函数指针或进程内对象。

模型 API key、IM token、数据库密码和 Tool 凭据只存在于 Secret Manager。Agent Factory 在可信 tenant_id 已确定后,用 Revision 的同租户引用解析运行时依赖并按授权注入。

工具采用显式 allowlist。未列出的 Tool、ToolSet、MCP Server 或 Skill 不得因平台注册、 模型请求或名称碰撞自动出现在 Agent 中。

PostgreSQL 数据模型

以下 DDL 描述目标完整性和事务语义;本阶段 Go 实现仍使用 InMemory Repository。

Agent App 根表

CREATE TABLE agent_app (
    tenant_id       TEXT NOT NULL REFERENCES tenant(tenant_id),
    app_id          TEXT NOT NULL
                    CHECK (app_id ~ '^app_[0-7][0-9A-HJKMNP-TV-Z]{25}$'),
    app_key         TEXT NOT NULL
                    CHECK (app_key ~ '^[a-z][a-z0-9-]{1,63}$'),
    display_name    TEXT NOT NULL
                    CHECK (length(btrim(display_name)) BETWEEN 1 AND 200),
    description     TEXT NOT NULL DEFAULT ''
                    CHECK (length(description) <= 2000),

    status          TEXT NOT NULL DEFAULT 'draft'
                    CHECK (status IN ('draft', 'active', 'suspended', 'disabled')),
    current_revision BIGINT,

    version         BIGINT NOT NULL DEFAULT 1 CHECK (version >= 1),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),

    PRIMARY KEY (tenant_id, app_id),
    UNIQUE (tenant_id, app_key),
    CHECK (
        (status = 'draft' AND current_revision IS NULL)
        OR (status IN ('active', 'suspended') AND current_revision IS NOT NULL)
        OR status = 'disabled'
    )
);

app_key 只在租户内唯一。不同租户可以使用相同 key;Gateway 和 Admin API 必须先从可信 边界解析 tenant_id,再解释 App key。

Agent App Revision

CREATE TABLE agent_app_revision (
    tenant_id       TEXT NOT NULL,
    app_id          TEXT NOT NULL,
    revision        BIGINT NOT NULL CHECK (revision >= 1),

    state           TEXT NOT NULL DEFAULT 'draft'
                    CHECK (state IN ('draft', 'published')),
    draft_version   BIGINT NOT NULL DEFAULT 1 CHECK (draft_version >= 1),
    agent_kind      TEXT NOT NULL CHECK (agent_kind = 'llm'),
    schema_version  INT NOT NULL DEFAULT 1 CHECK (schema_version >= 1),

    description        TEXT NOT NULL DEFAULT '' CHECK (length(description) <= 2000),
    instruction        TEXT NOT NULL
                       CHECK (length(btrim(instruction)) BETWEEN 1 AND 65536),
    global_instruction TEXT NOT NULL DEFAULT ''
                       CHECK (length(global_instruction) <= 65536),

    -- model_profile 落地后增加同租户复合外键。
    model_profile_id TEXT NOT NULL CHECK (length(btrim(model_profile_id)) > 0),

    -- 只保存按 agent_kind + schema_version 校验过的无密钥配置。
    generation_config JSONB NOT NULL DEFAULT '{}'::jsonb,
    runtime_policy    JSONB NOT NULL DEFAULT '{}'::jsonb,

    content_digest    TEXT,
    published_at      TIMESTAMPTZ,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT now(),

    PRIMARY KEY (tenant_id, app_id, revision),
    FOREIGN KEY (tenant_id, app_id)
        REFERENCES agent_app(tenant_id, app_id),
    CHECK (
        (state = 'draft' AND content_digest IS NULL AND published_at IS NULL)
        OR
        (state = 'published'
         AND content_digest ~ '^[0-9a-f]{64}$'
         AND published_at IS NOT NULL)
    )
);

ALTER TABLE agent_app
    ADD CONSTRAINT fk_agent_app_current_revision
    FOREIGN KEY (tenant_id, app_id, current_revision)
    REFERENCES agent_app_revision(tenant_id, app_id, revision);

CREATE OR REPLACE FUNCTION agent_app_reject_identity_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
       OR NEW.app_id IS DISTINCT FROM OLD.app_id
       OR NEW.app_key IS DISTINCT FROM OLD.app_key THEN
        RAISE EXCEPTION 'agent app identity is immutable';
    END IF;
    RETURN NEW;
END;
$$;

CREATE TRIGGER agent_app_identity_immutable
BEFORE UPDATE ON agent_app
FOR EACH ROW EXECUTE FUNCTION agent_app_reject_identity_change();

CREATE OR REPLACE FUNCTION agent_app_revision_reject_published_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF OLD.state = 'published' THEN
        RAISE EXCEPTION 'published agent app revision is immutable';
    END IF;
    IF TG_OP = 'DELETE' THEN
        RETURN OLD;
    END IF;
    RETURN NEW;
END;
$$;

CREATE TRIGGER agent_app_revision_published_immutable
BEFORE UPDATE OR DELETE ON agent_app_revision
FOR EACH ROW EXECUTE FUNCTION agent_app_revision_reject_published_change();

JSONB 用于容纳框架的有界可选参数,不是绕开领域校验。Go 层必须解码到具体类型,拒绝未知 字段、未知 schema version、非法数值和任何 Secret 字段。框架默认值必须在发布时物化或 纳入 digest 语义,避免框架升级后同一 Revision 静默改变行为。

工具授权和默认引用

CREATE TABLE agent_app_revision_tool (
    tenant_id  TEXT NOT NULL,
    app_id     TEXT NOT NULL,
    revision   BIGINT NOT NULL,
    tool_id    TEXT NOT NULL,
    required   BOOLEAN NOT NULL DEFAULT false,

    PRIMARY KEY (tenant_id, app_id, revision, tool_id),
    FOREIGN KEY (tenant_id, app_id, revision)
        REFERENCES agent_app_revision(tenant_id, app_id, revision)
        ON DELETE CASCADE,
    CHECK (length(btrim(tool_id)) > 0)
);

CREATE OR REPLACE FUNCTION agent_app_revision_tool_guard()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    v_tenant_id TEXT;
    v_app_id TEXT;
    v_revision BIGINT;
BEGIN
    IF TG_OP = 'DELETE' THEN
        v_tenant_id := OLD.tenant_id;
        v_app_id := OLD.app_id;
        v_revision := OLD.revision;
    ELSE
        v_tenant_id := NEW.tenant_id;
        v_app_id := NEW.app_id;
        v_revision := NEW.revision;
    END IF;

    IF EXISTS (
        SELECT 1 FROM agent_app_revision
        WHERE tenant_id = v_tenant_id
          AND app_id = v_app_id
          AND revision = v_revision
          AND state = 'published'
    ) THEN
        RAISE EXCEPTION 'published agent app tool authorization is immutable';
    END IF;

    IF TG_OP = 'DELETE' THEN
        RETURN OLD;
    END IF;
    RETURN NEW;
END;
$$;

CREATE TRIGGER agent_app_revision_tool_immutable
BEFORE INSERT OR UPDATE OR DELETE ON agent_app_revision_tool
FOR EACH ROW EXECUTE FUNCTION agent_app_revision_tool_guard();

ALTER TABLE tenant
    ADD CONSTRAINT fk_tenant_default_agent_app
    FOREIGN KEY (tenant_id, default_agent_app_id)
    REFERENCES agent_app(tenant_id, app_id);

后续 Tool Registry 落地后,tool_id 必须增加同租户复合外键或等价校验。数据库外键只能 保证默认 App 的归属和存在;设置默认对象时,Admin API 还必须验证 App active 且存在有效 当前 Revision。暂停或停用默认 App 时,不得静默回退到其他 App。

生产角色不能直接修改发布表或绕过上述触发器。与 Tenant 状态变更相同,发布、回滚和状态 迁移应通过固定 search_path 的受控函数完成,并先撤销 PostgreSQL 默认授予 PUBLIC 的 函数执行权限。Migration owner 不属于运行时连接池;Worker 只消费执行快照,不读取草稿或 枚举其他租户的 App。

生命周期

                         首次发布
                  draft ─────────────> active
                                          │  ▲
                                 暂停     │  │ 恢复
                                          ▼  │
                                      suspended
                                          │
                         停用             │ 停用
                  draft ─────────────┐    │
                                    ▼    ▼
                                    disabled
                                      终态
  • draft:没有已发布 Revision,允许维护草稿,不接受执行。
  • active:存在 current_revision,允许创建新的执行快照。
  • suspended:保留当前 Revision 和数据,拒绝新执行,可以恢复。
  • disabled:终态,拒绝发布、回滚、恢复和新执行。

App active 后可以继续创建草稿,根状态仍为 active,现有流量继续使用当前 Revision,直到 新草稿成功发布。草稿不完整或发布失败不能影响当前流量。

Tenant 与 App 是连续的两道门禁:只有两者都 active 才能创建新执行快照。已创建快照按既定 取消和收尾策略完成,暂停不篡改进行中的快照。

草稿、发布与回滚

草稿更新同时校验 expected_app_versionexpected_draft_version,分别防止状态/发布操作 与草稿编辑发生并发覆盖。实现可以允许多个草稿,但每次发布必须显式指定 Revision,不能 使用“最新一条”之类存在竞态的查询。

发布事务

发布必须在一个控制面事务中完成:

  1. (tenant_id, app_id) 锁定 App,校验 expected app version。
  2. 锁定指定草稿,校验 expected draft version。
  3. 验证 App 未 disabled、Tenant active、Revision schema 完整。
  4. 验证模型、Tool、Knowledge 和 Backend 引用属于同一租户且可发布。
  5. 对规范化执行配置计算 content digest。
  6. 将 Revision 冻结为 published,写入 digest 和发布时间。
  7. 原子更新 current_revision;首次发布把 App 从 draft 变为 active。
  8. 递增 App version 并写 Publish Outbox/Audit 事件。

任一步失败都必须回滚,旧 current_revision 继续提供服务。

摘要使用 SHA-256 小写十六进制,输入为确定性序列化后的 agent kind、schema version、 Prompt、模型引用、排序后的工具授权、generation config 和 runtime policy。摘要不包含时间、 actor 或 draft version。Map key、集合顺序和空值语义必须规范化。摘要用于缓存和审计,不是 签名或授权凭据。

回滚

回滚只允许选择同一 Tenant、同一 App 的历史 published Revision。事务校验 expected app version,切换 current_revision,递增 App version 并记录事件。目标 Revision 不复制、 不重编号、不修改 digest。回滚只影响后续快照,进行中的请求继续使用原 Revision。

审计事件

发布、回滚和状态迁移返回结构化事件:

字段 说明
event_type published、rolled_back 或状态事件
tenant_id / app_id 显式租户和应用边界
previous_revision / current_revision 变更前后发布指针
content_digest 当前 Revision 摘要
actor_type / actor_id 可信变更主体
reason 1–1000 字符的非空原因
correlation_id 管理请求或发布流程关联 ID
previous_version / next_version App 乐观锁版本
occurred_at UTC 发生时间

InMemory Repository 把事件作为调用结果返回。生产 SQL Repository 必须在同一事务中更新 App 并写 Outbox,不能先发布成功再尽力写审计。

Go 目录与 Repository

沿用仓库现有 trpcservice/agent 责任域,不新增平级的 trpcservice/agentapp。App、Revision、 Repository 契约和执行快照放在 agent 根包;单进程实现放在 agent/inmemory。后续 Agent Factory 仍由 agent 包负责,但领域状态、仓储和运行时装配应按文件保持清晰边界。

trpcservice/agent/
├── agent.go          # 包说明及 tRPC-Agent-Go 复用边界
├── app.go            # App 根实体、生命周期和校验
├── revision.go       # 草稿、发布版本及摘要
├── repository.go     # 控制面 Repository 契约
├── runtime.go        # 执行快照和 Factory 输入边界
└── inmemory/
    └── inmemory.go   # 单进程开发/测试实现

若后续 Graph/Chain 等实现使根包职责过大,应先通过设计 Issue 决定子包边界,不能在本阶段为 假设性扩展创建重复的 agentapp 抽象。

type Repository interface {
    Create(context.Context, CreateInput) (*AgentApp, error)
    Get(context.Context, string, string) (*AgentApp, error)
    CreateDraft(context.Context, CreateDraftInput) (*Revision, error)
    UpdateDraft(context.Context, UpdateDraftInput) (*Revision, error)
    GetRevision(context.Context, string, string, int64) (*Revision, error)
    Publish(context.Context, PublishInput) (*AgentApp, *Revision, ChangeEvent, error)
    Rollback(context.Context, RollbackInput) (*AgentApp, ChangeEvent, error)
    TransitionStatus(context.Context, TransitionStatusInput) (*AgentApp, ChangeEvent, error)
}

实际 API 应使用清晰参数名或输入结构体,避免混淆 tenant ID 与 app ID。任何按 app key 的查询 也必须同时接收 tenant ID。

实现必须遵守:

  • 调用开始、等待锁后和提交修改前检查 context.Context 取消。
  • 读写返回深拷贝,特别是工具切片、JSON Map 和可选指针。
  • app_key 唯一索引按 tenant 分区。
  • Revision 编号只在同一 App 内单调分配。
  • version 冲突返回可识别的 sentinel 或 typed error。
  • InMemory 只用于单进程开发和测试,不承诺持久化或跨 Worker 一致性。

执行快照与 Agent Factory

AgentExecutionSnapshot 是一次 Worker 执行的不可变输入,至少包含 Tenant ID/version、 App ID/key/version、固定 Revision/content digest、LLMAgent 无密钥配置及依赖引用。

快照构造器连续验证:

  1. Tenant snapshot 来自受保护构造边界且状态 active。
  2. App 属于同一 Tenant、状态 active,并存在当前 Revision。
  3. Revision 属于同一 App、状态 published,编号等于 current_revision
  4. 内容摘要有效,schema version 受支持。

快照内部状态必须封闭,访问器返回防御性副本。零值或手工构造的畸形值不得通过 Context 注入成为可信配置。

平台 trpcservice/agent Factory 根据快照完成运行时装配:

Revision 配置 tRPC-Agent-Go 边界
App key / 展示元数据 LLMAgent name 与 description;稳定运行身份仍使用 App ID
instruction / global instruction LLMAgent Instruction / GlobalInstruction
model profile ref 同租户 Model Registry 解析为 model.Model
tool allowlist 同租户 Tool Registry 解析为 tool.Tool / tool.ToolSet
generation config model.GenerationConfig 的受支持字段
runtime policy Tool 并行、并发、循环和有界执行选项
tenant/app/revision/digest Factory 缓存键及 OTel attributes

Factory 复用 tRPC-Agent-Go 的 LLMAgent、Agent、Runner、Tool、Session 和 Memory。平台层只负责 租户授权、配置解析、依赖注入、缓存和审计,不复制框架执行循环。

Factory 缓存键至少包含:

tenant_id + app_id + revision + content_digest

不能只用 app key、展示名或“当前版本”。发布和回滚产生新快照键,旧缓存项在已有执行释放 引用后按容量或 TTL 回收。

验证策略

实现测试至少覆盖:

  • App ID/key、展示字段、状态、时间和 version 边界。
  • 相同 app key 在不同租户允许、同租户拒绝。
  • Repository 的显式租户隔离和深拷贝。
  • 草稿 version 冲突和并发更新单一获胜。
  • 首次/连续发布、失败发布保持旧版本、历史回滚。
  • published Revision 不可修改。
  • draft、suspended、disabled、无当前 Revision 以及 Tenant 非 active 的门禁。
  • context 已取消和等待锁时取消。
  • 发布、回滚、状态事件的 actor、reason、correlation 和版本字段。
  • InMemory Repository race 测试。

后续顺序

tenant
  └── agent_app + revision
        ├── backend_profile
        ├── model/tool/knowledge references
        └── channel_binding
              └── Gateway / Worker 最小执行链路

Agent App 闭环后优先设计 backend_profile,使 Revision 的数据能力引用和 Tenant 默认后端 可通过同租户复合约束落地;随后设计 channel_binding,把验签后的外部账号路由到已发布 App。