# Agent Gateway 消息级 Agent Model 实现方案

日期：2026-09-07  
状态：已实现，本地验证通过；真实底层模型联调未执行

## 1. 目标与设计决策

允许调用方通过 `ProcessMessageRequest.context.agent_model` 指定底层 Agent 使用的模型，并将现有顶层 `agent_id` 迁移至 `MessageContext`。

`MessageContext` 统一承载消息执行所需的来源实例、工作目录、Agent 和模型选择；消息正文继续由 `ApplicationMessage` 或 `LarkMessage` 承载。`ProcessMessage` 与 `ProcessLarkMessage` 共享这些执行参数和错误语义。

两个字段的生命周期不同：

- `AgentID` 选择已配置的逻辑 Agent，并继续绑定到 Session；已有 Session 不允许切换 Agent。
- `AgentModel` 是本次新 turn 的可选模型覆盖值；同一 Session 的后续新 turn 可以选择其他模型。

Ticket Manager 同步调整配置职责：现有各业务场景的 `agentID` 改为 `agentModel`，Agent 选择统一收敛至 `services.agent.agentID`，默认空字符串。业务场景选择模型，Agent Service 选择运行这些场景的逻辑 Agent。

服务尚未正式上线，本方案不考虑 Proto 兼容性。直接移除顶层 `ProcessMessageRequest.agent_id`，不保留双字段兼容逻辑或旧字段的 `reserved` 声明；仓库内调用方、SDK 与 Gateway 一起修改。

## 2. 变更前实现

当前调用链如下：

```text
ProcessMessage / ProcessLarkMessage
  -> Gateway Handler
  -> Gateway Service
  -> TaskService.Process
  -> 保存 Task 并异步入队
  -> requestFromTask
  -> Runtime.Exec
  -> Codex app-server / Codex CLI
```

需要处理的现有行为：

- `MessageContext` 目前只有 `instance_id` 和 `working_directory`；`agent_id` 位于 `ProcessMessageRequest` 顶层。
- `TaskService.ensureSessionLocked` 负责选择 Agent，并拒绝已有 Session 的 Agent 切换。
- Task 持久化后异步执行；Gateway 启动时通过 `resumePending` 恢复 RECEIVED 状态的 Task。
- 存在 active turn 时，执行路径会将消息发送到 `Runtime.Steer`；目前有进入执行时、取得 Session 锁后两处判断。
- 两个 Provider 都从静态配置读取模型。app-server 的请求构造已支持 `model`，CLI 已支持模型命令行参数。
- Provider 的 `Status` 当前将配置模型放在 `model` 字段中，并不查询实际运行模型。

因此，本次修改必须覆盖契约、Task 持久化、恢复路径和两个 Provider，不能只在 HTTP/gRPC Handler 中增加透传。

## 3. API 契约

修改 `packages/contracts/agent-gateway/api/agent-gateway/gateway/v1/gateway_service.proto`：

```proto
message MessageContext {
  string instance_id = 1;
  string working_directory = 2;
  string agent_id = 3;
  string agent_model = 4;
}

message ProcessMessageRequest {
  MessageContext context = 1;
  aisupport.agent_gateway.entity.gateway.v1.ApplicationMessage message = 2;
}
```

`ProcessLarkMessageRequest` 继续引用同一个 `MessageContext`，无需增加顶层字段。Proto 注释应说明两个 Agent 字段的默认行为、Session 约束和 steer 时忽略模型覆盖值的语义。

HTTP 请求结构示例（模型名仅表示调用方选择的已配置可用模型）：

```json
{
  "context": {
    "instanceId": "ticket-manager",
    "workingDirectory": "/projects/workspace-1",
    "agentId": "codex",
    "agentModel": "<model-id>"
  },
  "message": {
    "messageId": "step-1",
    "sessionKey": "handler:session-1",
    "senderEmail": "user@example.com",
    "content": "分析当前问题"
  }
}
```

为 Task 增加字段，修改 `packages/contracts/agent-gateway/entity/agent-gateway/session/v1/session.proto`：

```proto
// 调用方为本 Task 指定的模型覆盖值；空值表示使用 Provider 默认行为。
// 此字段不表示底层确认的实际运行模型。
string agent_model = 11;
```

该字段应添加在现有 `message Task` 中。Session 不增加模型字段；TaskEvent 不增加模型字段，查询和响应中的 Task 可返回请求模型。

## 4. 参数语义

| 场景 | 行为 |
| --- | --- |
| 新 Session，`agent_id` 为空 | 选择 Gateway 默认 Agent |
| 新 Session，指定 `agent_id` | 选择该逻辑 Agent；不存在则返回 `InvalidArgument` |
| 已有 Session，`agent_id` 为空或与绑定值相同 | 继续使用 Session Agent |
| 已有 Session，指定其他已注册 Agent | 返回 `FailedPrecondition` |
| 新 turn，指定 `agent_model` | 使用请求模型覆盖 Agent 配置模型 |
| 新 turn，`agent_model` 为空 | 使用 Agent 配置模型；配置也为空时保持底层默认行为 |
| 同 Session 的后续新 turn 指定其他模型 | 允许，不更改 Session Agent |
| 存在 active turn，消息进入 steer | 忽略本次 `agent_model`，沿用运行中 turn 的模型 |
| 新 turn 使用的模型不存在或底层拒绝该模型 | Task 执行失败，通过现有终态和回调报告 |
| 重复消息 | 保持现有 Agent 校验及去重顺序，返回原 Task，不更新原模型参数 |

模型解析优先级为：请求覆盖值 > Agent 配置模型 > 底层默认行为。只持久化请求覆盖值，不在接收阶段快照配置默认模型；空值 Task 恢复执行时仍使用当时的 Provider 配置。

当请求和 Agent 配置均未指定模型时，Gateway 不承诺重置底层 thread 已有的模型状态。显式覆盖不会成为 Gateway 的 Session 默认值，但底层在参数缺省时是否沿用 thread 状态，仍由底层行为决定。

Gateway 不维护新的模型白名单，不增加模型目录查询接口。`agent_model` 作为字符串原样传递，不新增 Trim、别名转换或模型对应的 reasoning effort 推断。

`/status`、`/agents` 等已有命令不启动模型推理，继续执行原命令逻辑；模型参数不改变其行为。

## 5. Gateway 与 Task 执行

### 5.1 Gateway 参数映射

- 移除内部 `gatewayapi.MessageContext`，内部两个消息请求直接持有 Proto 的 `*MessageContext`。
- 删除内部 `gatewayapi.ProcessMessageRequest.AgentID`。
- Handler 直接传递 `req.GetContext()`，删除 `messageContext` 转换函数；Gateway Service 通过 Proto getter 读取字段，Context 缺省时保持原有参数校验行为。
- Application 与 Lark 两个入口都将 Context 中的 Agent 参数传给 `taskapi.ProcessRequest`。
- 两个入口对 `ErrAgentNotFound` 和 `ErrSessionAgentMismatch` 使用一致的 gRPC 错误映射。

### 5.2 Task 保存与恢复

- `taskapi.ProcessRequest` 增加 `AgentModel string`。
- 创建 Task 时保存请求中的模型覆盖值。
- `requestFromTask` 从 Task 恢复 `AgentModel`。
- `agentapi.ExecRequest` 增加 `AgentModel string`，正常执行路径完整透传。
- 模型不参与 Session key、消息去重键或 Agent 路由。

恢复显式指定模型的 RECEIVED Task 时，必须使用持久化的请求值。已有恢复边界保持不变，本次不扩展 PROCESSING Task 的重放能力。

### 5.3 Active turn 与 steer

普通消息执行时，如果 Session 存在 active turn，无论是否携带 `AgentModel`，都沿用现有 steer 行为。steer 时默认忽略本次指定的模型，继续使用运行中 turn 的模型。

1. 按现有流程更新 Task 的 STEERING 状态并调用 `Runtime.Steer`，不启动新 turn。
2. 不向底层 steer 传递模型参数，不校验或比较请求模型与运行模型，也不因指定模型而拒绝消息。
3. 保留 Provider 原有的 steer 接受、拒绝和错误处理，以及相应的 Task 终态通知。

两处 active-turn 分支遵循同一规则，沿用现有 Session 锁及 turn-start 解锁机制。是否应用模型覆盖值由实际执行路径决定：启动新 turn 时应用，进入 steer 时忽略，不能只依据请求接收时的忙闲状态决定。

Task 的 `agent_model` 仍保留原始请求覆盖值，用于表达调用方输入；steer 时不清空该字段，也不将它解释为实际运行模型。被忽略的模型不会被排队应用到后续 turn，后续新消息需要自行指定所需模型。

消息去重行为保持不变：复用原 `message_id` 只会取得原 Task，不会再次 steer 或应用模型覆盖值。

## 6. Provider 实现

### 6.1 Codex app-server

修改 `services/agent-gateway/internal/services/agent/providers/codex-app-server/provider.go`：

- 由完整 `ExecRequest` 构造本次调用的 `threadOptions`。
- `req.AgentModel` 非空时覆盖 `cfg.Model`。
- 新建 thread、恢复 thread、启动 turn 使用同一份本次 options，保持三处模型一致。
- 不修改 `p.cfg`，避免并发请求的模型相互影响。
- `SteerRequest` 和底层 steer 参数保持不变。

### 6.2 Codex CLI

修改 `services/agent-gateway/internal/services/agent/providers/codex-cli/cli.go`：

- 参数构造使用请求模型优先、配置模型兜底的规则。
- `exec` 与 `exec resume` 都传入解析后的模型参数。
- 参数仍通过 `exec.CommandContext` 的独立 argv 传递。
- 两者均为空时保留现有不传模型参数的行为。

### 6.3 Status 表达

两个 Provider 的 Status 将配置模型明确命名为 `default_model`，同步调整相关测试和 README。该值表示 Agent 配置默认值，不表示当前或最近一次 turn 的实际模型；空值表示未配置。

本次不新增有效模型查询、Session 模型状态或底层响应模型审计。

## 7. 数据库迁移

修改 `services/agent-gateway/models/task.go`，增加 `AgentModel string`，数据库列为 `agent_model`，使用显式 `text` 类型、非空及空字符串默认值。

同步更新 `internal/services/session/repo/cvt` 中 Task 的双向转换，并在 `migrate/migrate.go` 注册独立的加列迁移，例如 `202609071200_add_task_agent_model`。

迁移需要兼容两条路径：

- 全新数据库：`initDB` 的 AutoMigrate 已创建新列，后续加列迁移应跳过。
- 已有数据库：新增列，旧 Task 的模型覆盖值为空。

迁移应先检查列是否存在，再执行加列；只更新 Go 模型或旧初始化函数不能完成已部署数据库的升级。数据库迁移不修改已有 Task 的状态、模型默认策略或 Session 绑定。

## 8. SDK、调用方与生成代码

| 范围 | 修改内容 |
| --- | --- |
| `packages/sdk-go/infra/agentgateway/application` | 保持克隆请求后补充实例和工作目录的逻辑；验证 Context 中 Agent 参数完整保留且不修改原请求 |
| `packages/sdk-go/infra/agentgateway/lark` | 支持 Ticket Manager 将 Agent Service 配置的 AgentID 传入消息 Context；其他未配置调用方保持默认行为 |
| `services/ticket-manager/config/services_config.go` | 场景级 AgentID 改为 AgentModel；AgentServiceConfig 增加默认空值的 AgentID |
| `services/ticket-manager/internal/services/agent/api/service.go` | 业务 TaskRequest 的 AgentID 改为可选 AgentModel，并注明新 turn 与 steer 语义 |
| `services/ticket-manager/internal/services/agent/service.go`、`task_runner.go` | 从 Agent Service 配置统一取得 AgentID，写入 MessageContext，并透传场景指定的 AgentModel |
| Ticket Manager Dispatch、Workspace timeline、task advisor | 将场景配置、构造参数、请求赋值及日志中的模型选择统一改为 AgentModel |
| Ticket Manager 测试及 `e2e/fakegateway` | 迁移请求构造、`GetAgentId()` 读取和相关断言 |
| Ticket Manager `config.example.yaml`、README、AGENTS.md | 同步新配置及 Agent Service 统一选择 Agent 的职责说明 |
| `services/agent-gateway/README.md` | 更新 JSON 示例、Agent 选择、模型优先级、steer 时忽略模型的语义及 Status 字段说明 |

通过 `services/agent-gateway` 的 `make generate-proto` 执行 Buf lint 和生成，更新 Go Proto、gRPC、grpc-gateway 及 Swagger 产物，不手改生成代码。需要更新 Mock 时使用现有 mockgen 工作流。

检索仓库内全部 `ProcessMessageRequest` 构造与字段读取，统一迁移至新的 Context 结构，避免误改其他 API 中语义不变的 `AgentId`。生成产物和调用方以新契约为准，不维护旧版请求的兼容测试或过渡实现。

### 8.1 Ticket Manager 配置迁移

修改 `services/ticket-manager/config/services_config.go`，按以下路径迁移：

| 原配置 | 新配置 | 默认值与含义 |
| --- | --- | --- |
| `services.dispatch.triage.agentID` | `services.dispatch.triage.agentModel` | 空字符串，使用所选 Agent 的默认模型行为 |
| `services.workspace.timeline.agentID` | `services.workspace.timeline.agentModel` | 空字符串，使用所选 Agent 的默认模型行为 |
| `services.workspace.taskAdvisor.addTask.agentID` | `services.workspace.taskAdvisor.addTask.agentModel` | 空字符串，使用所选 Agent 的默认模型行为 |
| 无 | `services.agent.agentID` | 空字符串，新 Session 交由 Gateway 选择默认 Agent，已有 Session 沿用绑定 Agent |

删除场景配置中的旧 `AgentID` 字段和 YAML key，不保留别名兼容。Dispatch 原来的 `default:"dispatch-triage"` 和 `validate:"required"` 一并删除；`dispatch-triage` 是逻辑 Agent ID，不能直接改作模型默认值。三个 `AgentModel` 均为可选字符串，显式配置时填写底层可用的模型 ID。

`AgentServiceConfig.AgentID` 使用 `yaml:"agentID"`，默认空字符串，不增加必填校验。示例配置为：

```yaml
services:
  agent:
    agentID: "" # 新 Session 使用 Gateway 默认 Agent
  dispatch:
    triage:
      agentModel: "" # 使用所选 Agent 的默认模型行为
  workspace:
    timeline:
      agentModel: ""
    taskAdvisor:
      addTask:
        agentModel: ""
```

这是一段配置片段；其他现有配置保持原职责。若需要保留某个场景原先的模型选择，应将原逻辑 Agent 对应的实际模型 ID 显式填入该场景的 `agentModel`，不能仅重命名配置键并保留 Agent ID 字符串。

### 8.2 Ticket Manager 调用链与统一 Agent 选择

- Dispatch 的 `TriageAgentID` 组件参数、triage executor/runner 的 `agentID` 字段和构造参数改为 `AgentModel` 对应命名，并从 `Services.Dispatch.Triage.AgentModel` 读取。
- Workspace timeline 和 add-task advisor 从各自 `AgentModel` 配置生成 `TaskRequest.AgentModel`，业务层不再传入 AgentID。
- Ticket Manager 业务 `TaskRequest` 删除 AgentID，增加 AgentModel；Gateway 的公共 MessageContext 仍保留两个字段。
- Agent Service 从 `Services.Agent.AgentID` 取得统一 AgentID，在 TaskRunner 提交请求时写入 `context.agent_id`，同时将 `TaskRequest.AgentModel` 写入 `context.agent_model`。`Submit` 与 `RunAndWait` 使用相同规则。
- Agent Service 的直接 `SendApplicationMessage` 入口和 Lark 消息入口同样使用该统一 AgentID。检查实际 SDK 调用路径，补齐必要的配置注入，避免配置只对 TaskRunner 生效。直接 Application 请求中的模型覆盖值继续保留；Lark 普通消息未配置场景模型时使用空模型值。
- Ticket Manager 的所有入口以 Agent Service 配置为 AgentID 来源，不提供场景或单次业务请求的 AgentID 覆盖。配置为空时传空值，由 Gateway 执行默认选择或 Session 绑定规则。
- 统一选择的 Agent（包括 Gateway 默认 Agent）必须具备所有启用场景所需的技能与工作目录访问能力，尤其是 Dispatch 使用的 `triage` skill；模型配置只改变模型，不提供原专用 Agent 的其他能力。

共享 SDK 的改动仅承担所需的配置传递，Ticket Manager 的统一选择策略由 Ticket Manager 的 Agent Service 负责；其他 SDK 调用方仍可按公共契约指定 AgentID 和 AgentModel。

### 8.3 Session key、日志与恢复

当前 TaskRunner 将请求 AgentID 编入版本化 Session key，并在回调解析时恢复该字段。迁移 `TaskRequest` 时需要同步调整相关辅助函数，不能将原 AgentID 槽位机械替换成 AgentModel。

- Session key 中现有 AgentID 槽位使用 Agent Service 配置的 AgentID，允许为空；继续保留 scene、business key、run ID 和提交模式的关联语义。
- AgentModel 不进入 Session key、等待器 key 或去重键；同一次业务运行切换模型不应创建另一组会话关联。
- 回调解析使用内部关联结构保存编码的 AgentID，不再依赖业务 TaskRequest 暴露 AgentID；回调日志使用原 Session key 中的值，避免配置变化后错误记录成当前 AgentID。
- 提交与等待日志明确区分 `agent_id`（Agent Service 配置的请求 Agent）和 `agent_model`（场景请求模型）。模型未包含在现有 TaskEvent 和 Session key 中，回调无法恢复时不推断、不伪造模型字段。
- 保留现有等待、超时、timeline 周期恢复及旧回调隔离行为。同步更新 Session key 编解码、空 AgentID、日志断言和集成测试。

## 9. 验证与验收

使用标准 `testing`、`testify` 和现有生成 Mock；新增独立用例尽可能使用 `t.Parallel()`。

| 层级 | 必须覆盖的行为 |
| --- | --- |
| 契约与 Handler | gRPC 与 HTTP Context 参数正确映射；两个入口均传递 AgentID 和 AgentModel |
| Gateway | 两个入口的未知 Agent、Session Agent 不匹配错误映射一致 |
| Task | 显式模型持久化并传入 Runtime；空值保持默认行为；同 Session 后续新 turn 可指定其他模型 |
| 去重 | 重复消息返回原 Task，即使请求模型变化也不覆盖或再次执行 |
| 恢复 | 保存后重新读取并恢复 RECEIVED Task，模型参数不丢失 |
| Steer | 两处 active-turn 路径在显式模型或空模型下均调用 Runtime.Steer；显式模型被忽略且不触发校验或新 turn；Task 保留请求模型；Provider 接受、拒绝及错误仍产生原有终态通知 |
| app-server | thread/start、thread/resume、turn/start 的模型一致；请求覆盖、配置兜底和参数缺省均正确 |
| CLI | 新建及 resume 参数覆盖正确；有覆盖调用之后的无覆盖调用不继承请求值 |
| 并发隔离 | 同 Provider 的不同请求不会修改共享配置或串用模型参数 |
| 持久化及迁移 | 新库和旧库迁移成功，旧行值为空，重复运行安全，Proto 与数据库模型往返不丢字段 |
| 调用方 | SDK 克隆保留 Agent 参数；TaskRunner 将两个字段写入 Context |
| Ticket Manager 配置 | services.agent.agentID 缺省或显式空值均为空，显式值正确加载；三个场景配置改为 agentModel，缺省为空，Dispatch 不再默认 dispatch-triage |
| Ticket Manager 场景 | Dispatch、timeline、task advisor 均传递各自模型，并使用同一个 Agent Service AgentID；直接 Application 与 Lark 入口也应用统一 AgentID |
| Ticket Manager 关联与日志 | 模型变化不改变 Session key 或等待器关联；回调恢复原请求 AgentID；日志区分 Agent 与请求模型；保持恢复与旧回调隔离 |
| Status | 返回 default_model，避免把配置默认值呈现为运行模型 |

从各自 Go module 执行检查：

```sh
# services/agent-gateway
make generate-proto
go test ./...

# packages/sdk-go/infra
go test ./agentgateway/...

# services/ticket-manager
APP_ENV=local go test ./config/... ./internal/services/agent/... ./internal/services/dispatch/... ./internal/services/workspace/... ./internal/integrations/agentgateway/... ./e2e/fakegateway/...
```

对新增的并发隔离及 steer 测试运行对应包的 `go test -race`。最后执行 `git diff --check`，确认生成文件与源契约一致，无无关依赖变化。

真实底层联调应验证同一 Agent 的新建和 resume 均应用指定模型，并验证无效模型会进入 Task 失败回调。若环境未提供可用底层，需明确记录该验证未执行，不能将本地参数构造测试当作真实模型切换证据。

## 10. 实施顺序

1. 更新 Proto 源契约并生成 SDK、Gateway 和 Swagger。
2. 增加 Task 数据库列、迁移及双向转换。
3. 完成 Gateway、Task 保存恢复、Runtime 透传，并保持 steer 忽略模型覆盖值的行为。
4. 更新两个 Provider 及 Status 表达。
5. 迁移 SDK、Ticket Manager 场景配置与调用方，将 AgentID 收敛至 Agent Service；同步 Session key、日志、测试和使用说明。
6. 执行分层测试与可用的底层联调。

服务尚未正式上线，本次无需设计旧 Proto 客户端兼容、分阶段切换或停流发布流程。数据库加列迁移仍保留，用于已有开发或测试数据库的正常启动升级；它不构成旧 API 兼容要求。

本次交付包含实现代码、生成契约、数据库迁移、测试和使用说明。模型目录、运行中换模、自动排队等待换模、Session 模型默认值及部署配置重构不在本次范围内。


## 11. 实施结果

已完成契约及生成代码、Gateway 请求映射、Task 模型持久化与迁移、恢复透传、两个 Provider 的请求模型覆盖，以及 Ticket Manager 的统一 AgentID 配置和场景 AgentModel 迁移。steer 保持原流程并忽略模型覆盖值。

Ticket Manager 的直接 Application、Lark、Submit 和 RunAndWait 入口统一应用 Agent Service 配置。Session key 保留配置 AgentID，不包含模型；回调恢复原请求 AgentID。e2e 配置生成脚本同步使用 `AGENT_ID` 和 `TIMELINE_AGENT_MODEL`，避免继续生成旧场景配置。

本地验证结果：

- `make generate-proto`：Buf lint 和生成通过。
- Agent Gateway `go test ./...`：通过，包含新库/旧库迁移测试。
- Infra SDK `go test ./agentgateway/...`：通过。
- Ticket Manager 第 9 节列出的受影响包测试：通过，包含本地 gRPC 提交与 HTTP 回调集成测试。
- Task、Codex app-server、Codex CLI 包的 `go test -race`：通过，覆盖两处 active-turn 判断、模型持久化/恢复、重复消息及模型选择隔离。
- e2e 配置脚本 `bash -n` 和 `git diff --check`：通过。

首次沙箱运行中，本地 HTTP/Unix socket 测试服务器无法监听；允许本地监听后相关测试通过。真实底层模型切换、真实数据库升级及 Docker e2e 本次未执行；Provider 参数和持久化行为的测试不能替代这些联调结果。
