> ## Documentation Index
> Fetch the complete documentation index at: https://niceeval.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 编写 Send

> 分七步编写 Adapter 的 send 函数：发送消息、续接会话、记录用量、映射工具事件、处理人工介入（HITL）、接入 OTel trace，并透传 Experiment flags。

[Adapter](/docs/zh/explanation/adapter) 定义了 `send` 的契约：接收 `TurnInput` 和 `AgentContext`，返回 `Turn`。本教程从发送一条消息开始，逐步加入完整接入所需的能力。每一步只增加少量代码，已有部分以 `// ……省略` 标出；步骤末尾列出新增数据支持的断言。完成任一步后，都可以把对应断言写进评估用例，重跑 `npx niceeval exp`，再在 `niceeval view` 中检查多轮轨迹、用量、工具事件或待回答请求。

三个贯穿全文的原则：

* **连接用户前端正在使用的接口。** Adapter 调用相同端点并接收相同格式，不为评估用例新建接口，也不 import 应用内部代码直接调用函数。具体原因见[接入你的 Agent](/docs/zh/tutorials/connect-your-agent)。
* **只手写 transport。** URL、鉴权和请求体取决于应用。`niceeval/adapter` 提供从原始返回到标准事件流的转换器；`ctx.session` 提供会话续接与 HITL 暂停恢复所需的状态 API。
* **运行反馈走 `ctx`，不直接写终端。** 长步骤用 `ctx.progress(...)`；需要在运行后回顾的退化或异常上下文用 `ctx.diagnostic(...)`；无法继续时抛错。不要从 Adapter 调用 `console.log/error` 或写 `process.stdout/stderr`。

<img src="https://mintcdn.com/niceeval/DVHPjGPSBgMJunUx/images/agent-turn-roundtrip-zh.svg?fit=max&auto=format&n=DVHPjGPSBgMJunUx&q=85&s=63aeabae55ca59c414761030ec895d7f" alt="一次 t.send 的完整往返：评估用例调用 t.send，运行器组装 TurnInput 与 ctx，adapter 调用你的应用并返回标准事件流 Turn。" width="1240" height="340" data-path="images/agent-turn-roundtrip-zh.svg" />

## 确认应用接口形状

[NiceEval](https://niceeval.com/) 不定义新的应用协议。现有应用通常使用下列协议或其变体，内置转换器按这些响应形状提供：

| 协议                             | 定义方                                                                                   | 说明                                                                       | 参考                                                                                                                                                         |
| ------------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Chat Completions**           | OpenAI；业界事实标准，几乎所有模型服务商都提供兼容端点                                                        | 无状态一问一答：客户端每轮发完整 `messages` 列表，一个 JSON 回来                                | [API 参考](https://platform.openai.com/docs/api-reference/chat)                                                                                              |
| **Responses / Open Responses** | OpenAI（Responses API）；Open Responses 是基于它的开放规范，OpenAI 发起、Hugging Face 等社区共建，用于多提供商互操作 | 有状态、面向 agent：请求带 `previous_response_id` 由服务端续接历史，`output` 数组承诺记录全过程      | [API 参考](https://platform.openai.com/docs/api-reference/responses) · [Open Responses 规范](https://www.openresponses.org/specification)                      |
| **AI SDK（Vercel）**             | Vercel；TypeScript 生态的事实标准                                                             | 不是线上协议而是 SDK：`generateText` 返回完整结果，`useChat` 走它定义的 UI Message Stream 流协议 | [generateText 参考](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text) · [UI Message Stream 协议](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol) |

以下步骤使用 Chat Completions 形接口。Responses 形接口和流式接口的替换实现分别在第二步和第四步给出。

## 第一步：发一条消息，拿到回复

最小的 `send` 只做三件事：把 `input.text` 发给应用的接口，把回复放进一条 `message` 事件，报告本轮 `status`：

```ts theme={null}
// agents/chat-app.ts
import { defineAgent } from "niceeval/adapter";

const BASE_URL = "http://localhost:8080";   // 要按 experiment 切换地址，改成工厂参数：见接入指南

export default defineAgent({
  name: "chat-app",
  async send(input, ctx) {
    ctx.progress({ message: "等待 Chat API" });
    const res = await fetch(`${BASE_URL}/v1/chat/completions`, {  // ← 前端本来就在用的接口
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        messages: [{ role: "user", content: input.text }],
        model: ctx.model,                                         // ← experiment.model 经 ctx.model 到这里；应用接口不支持选模型就删掉这行
      }),
      signal: ctx.signal,                                         // ← 运行器的超时与取消
    }).then((r) => r.json());

    return {
      status: "completed",
      events: [{ type: "message", role: "assistant", text: res.choices[0].message.content }],
    };
  },
});
```

如果接口返回了可继续处理、但证据不完整的响应，报告 diagnostic 而不是把原始响应全部打印出来：

```ts theme={null}
if (!res.requestId) {
  ctx.diagnostic({
    code: "missing-request-id",
    level: "warning",
    message: "响应没有 request id，无法与服务端日志关联",
  });
}
```

`progress` 不落盘；diagnostic 会随 Attempt 保存并可通过 locator 回顾。HTTP 连接失败或响应无法解析时直接抛错，runner 会记录错误发生在 `agent.run`，并把 Attempt 标为 `errored`。

**这一步支持**：`t.reply`、`t.messageIncludes()`、Judge 的全部对话材料，以及 Experiment 侧的**模型对比**。`ctx.model` 来自 Experiment 的 `model`；运行器原样传递，Adapter 只负责转发。接入等级见 [Tier](/docs/zh/explanation/tier)。

它有两个明显的局限：每轮都是一场全新对话（第二次 `t.send` 接不上第一次），工具调用完全看不见。后面两步各解决一个。

## 第二步：接上之前的消息

运行器在会话上只承诺一件事：**同一条会话线的每次 `send` 拿到同一个 `ctx.session`，新会话线（评估用例的第一轮，或 `t.newSession()` 之后）拿到一个全新的。**

会话续接方式取决于应用接口形状。`ctx.session` 为两种常见模式分别提供一对存取器：

* **客户端带全量历史**（服务端无状态，每轮发完整消息列表：Chat Completions 形是典型）→ `ctx.session.history<TMsg>()`
* **服务端记历史**（接口收一个会话 id：Responses 形的 `previous_response_id`、各 SDK 的原生 session / thread）→ `ctx.session.id` + `ctx.session.capture(id)`

主线的接口是前者：

```ts theme={null}
// agents/chat-app.ts
import { defineAgent } from "niceeval/adapter";

const BASE_URL = "http://localhost:8080";

interface Msg { role: "user" | "assistant"; content: string | null }

export default defineAgent({
  name: "chat-app",
  async send(input, ctx) {
    const history = ctx.session.history<Msg>();  // ← 本会话线的历史槽；新会话线是空的
    const messages = [...history.get(), { role: "user" as const, content: input.text }];

    const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
      // ……method、headers、signal 同第一步，省略
      body: JSON.stringify({ messages, model: ctx.model }),        // ← 变化：发的是完整历史；model 同第一步转发
    }).then((r) => r.json());

    const reply = res.choices[0].message;
    history.commit([...messages, reply]);        // ← 写回；同一条线的下一轮 get() 自动带上

    return {
      status: "completed",
      events: [{ type: "message", role: "assistant", text: reply.content }],
    };
  },
});
```

注意这里**没有"第一轮"分支**：新会话线的 `history.get()` 自然返回空数组——"第一次发"的形态是新会话线的自然结果，不是要你判断的条件。也不需要在 `defineAgent` 上声明任何东西：接了 `ctx.session`，多轮就续得上；没接，每轮各是一场新对话。

应用接口收会话 id 的话，历史在服务端，Adapter 只记 id——`send` 里只改两处：

```ts theme={null}
// send 里：
//   请求体带上一轮的 id …… previous_response_id: ctx.session.id   ← 新会话线自然是 undefined
//   返回里抓到 id 就写回 …… ctx.session.capture(res.id)           ← 下一轮续接靠它
```

`capture` 只在还没记过 id 时落地，后端重复回传（甚至因 fork 变了）也不会覆盖正在续接的线。

**这一步支持**：多轮对话，以及 `t.newSession()` 的会话隔离。

## 第三步：记录消费

答对了但烧掉十倍 token 的 agent，不该跟省着用的拿一样的分。消费是 `Turn` 上和 `events`、`status` 并列的第四个字段 `usage`：应用接口回了用量就如实填上，运行器逐轮累加到会话线与整次运行。Chat Completions 形返回自带 `usage`，照抄进来——`send` 的其余部分和第二步完全一样：

```ts theme={null}
    // ……transport 与会话同第二步，省略
    return {
      status: "completed",
      events: [{ type: "message", role: "assistant", text: reply.content }],
      usage: {                                        // ← 变化：把接口回的用量如实报上
        inputTokens: res.usage.prompt_tokens,
        outputTokens: res.usage.completion_tokens,
      },
    };
```

`Usage` 的完整字段还有可选的 `cacheReadTokens` / `cacheWriteTokens`、请求次数 `requests`，以及 `costUSD`——网关回了实测成本就填它，优先于价格表估算。接口不回用量就整个不填 `usage`，其它断言不受影响。这段照抄也是过渡：下一步的官方转换器会连 `usage` 一起填好。

**这一步支持**：`t.maxTokens()` / `t.maxCost()` 评分器（`maxCost` 用 `costUSD` 或配置里的价格表估算），以及报告和 `niceeval view` 里的用量。

## 第四步：把工具解析成事件

应用的返回里不只有回复文本——Chat Completions 形返回的 `tool_calls` 记录了这轮调过什么工具。Adapter 最重要的工作就是**把接口的返回归一成标准事件流**：本轮发生的每件事一个对象，按真实发生顺序排进 `Turn.events`，对象是下面十种类型之一（各字段的实际值，[契约页有一轮的完整示例](/docs/zh/explanation/adapter)）：

```ts theme={null}
type StreamEvent =
  | { type: "message"; role: "assistant" | "user"; text: string }
  | { type: "action.called"; callId: string; name: string; input: JsonValue; tool?: ToolName }
  | { type: "action.result"; callId: string; output?: JsonValue;
      status: "completed" | "failed" | "rejected" }
  | { type: "skill.loaded"; skill: string; callId?: string }
  | { type: "subagent.called"; callId: string; name: string; remoteUrl?: string }
  | { type: "subagent.completed"; callId: string; output?: JsonValue;
      status: "completed" | "failed" }
  | { type: "input.requested"; request: InputRequest }
  | { type: "thinking"; text: string }
  | { type: "compaction"; reason?: string }
  | { type: "error"; message: string };
```

解析就是一张"返回字段 → 事件"的映射。手写出来长这样——`send` 的其余部分和第二步完全一样：

```ts theme={null}
    // ……transport 与会话同第二步，usage 同第三步，省略
    const reply = res.choices[0].message;

    const events: StreamEvent[] = [];
    for (const call of reply.tool_calls ?? []) {
      events.push({ type: "action.called", callId: call.id,
                    name: call.function.name, input: JSON.parse(call.function.arguments) });
    }
    if (reply.content) events.push({ type: "message", role: "assistant", text: reply.content });

    return { events, status: "completed", usage };
```

但这段循环通常**不用你写**。返回是标准形状时，官方转换器一行顶替上面全部——`events`、`status`、连第三步手抄的 `usage` 都在返回值里，拿来直接 `return`：

```ts theme={null}
    // ……transport 与会话同第二步，省略
    return fromChatCompletion(res);            // ← 上面那段映射加第三步的 usage，官方版
```

接口不是这个形状，就换对应的件：

| 接口响应形状                                      | 内置实现                                                                          | 需要编写的代码                   |
| ------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------- |
| Chat Completions 形返回                        | `fromChatCompletion(res)`                                                     | 无需手写映射                    |
| Responses 形返回                               | `fromResponses(res)`                                                          | 无需手写映射                    |
| AI SDK `generateText` 的完整结果                 | `fromAiSdk(result)`                                                           | 无需手写映射                    |
| 流式：SDK 原生事件透传（一帧一个完整单元）                     | `fromClaudeSdkMessages()` / `fromPiAgentEvents()` / `fromCodexThreadEvents()` | 无需手写映射                    |
| 流式：AI SDK 的 UI Message Stream（`useChat` 协议） | `uiMessageStreamAgent()`                                                      | 无需手写 `send` 或事件映射         |
| 流式：逐 token / 逐参数增量                          | 协议方官方 reducer；没有才用 `deltaStream({ toOps })`                                   | 一张"帧类型 → 操作"映射表           |
| 只有最终答案                                      | 手写几条 events                                                                   | 一条 `message` 起步（第一步就是这个档） |

内置转换器按响应形状工作，不假设具体应用协议。只有增量流且协议方没有现成 reducer 时才需要编写映射；映射只声明每一帧对应的操作，拼接、配对和落盘时机由 `deltaStream` 处理。

归一完成后，你吐哪种事件，评估用例作者就能写哪族断言：

| 你吐的事件                                            | 解锁的断言                                                                            |
| ------------------------------------------------ | -------------------------------------------------------------------------------- |
| `message`                                        | `t.reply`、`t.messageIncludes()`、judge 的对话材料                                      |
| `action.called` / `action.result`（靠 `callId` 配对） | `t.calledTool()` / `t.toolOrder()` / `t.maxToolCalls()` / `t.noFailedActions()`… |
| `input.requested`（配合 status `"waiting"`）         | `t.parked()`、`t.requireInputRequest()`、`t.respond()`（第五步）                        |
| `thinking` / `compaction` / `error`              | 对应断言与 o11y 计数                                                                    |

Chat Completions 响应**不保证包含完整过程记录**。应用可能在服务端完成工具循环，只返回最终答案。因此，`fromChatCompletion` 的返回不带完整性证明：`calledTool` 等正断言可用，`notCalledTool` 等负断言会提示证据不完整。Responses 协议要求 `output` 数组记录完整过程，`fromResponses` 的返回带完整性证明，负断言可信。两者的可信度差异来自接口契约。

**这一步支持**：`t.calledTool()`、`t.toolOrder()`、`t.maxToolCalls()`、`t.noFailedActions()` 等工具断言。

## 第五步：HITL

应用中途停下来等人（工具审批、等补充信息）时，`send` 两侧各有义务：

* **停轮**：返回 `status: "waiting"`，并且每个待回答的问题吐一条带稳定 `id` 的 `input.requested` 事件——`t.parked()`、`t.requireInputRequest()` 读它，回答靠这个 `id` 对位。
* **回答轮**：评估用例里的 `t.respond(...)` 到 Adapter 是**又一次普通的 `send`**（还是同一条会话线、同一份状态），人的裁决以结构化形式随 `input.responses` 到达，每条带 `requestId`、`optionId` 或 `text`（形态见[不同回答的入参](/docs/zh/explanation/adapter#不同回答的入参)）。Adapter 先把裁决交回应用，再接着取结果。被人拒绝的调用，`action.result` 的 `status` 置 `"rejected"` 而不是 `"failed"`——拒绝是人的决定、不是工具故障，`noFailedActions()` 不误伤。

"停轮时读了一半的现场"（比如一条读到一半的 SSE 流）也存在 `ctx.session` 上：停轮时 `ctx.session.hold(现场)`，回答轮开头 `ctx.session.take()` 取回——取到即清除，一次消费。

HITL 几乎总是发生在流式接口上（停在流中间），所以这一步的示例换成一个以 SSE 透传原生事件的应用——它同时用上前面几步的全部内容（完整可跑版本见 [tier1 示例](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1)）：

```ts theme={null}
// agents/my-app.ts
import { defineAgent, sseJsonFrames, fromPiAgentEvents, driveFrameStream } from "niceeval/adapter";
import type { AgentContext, PiAgentStream, SseFrameCursor } from "niceeval/adapter";
import type { Turn, TurnInput } from "niceeval";

const BASE_URL = "http://localhost:5299";               // 应用自己的端口，eval 不代管进程

interface Pending { cursor: SseFrameCursor<Frame>; stream: PiAgentStream; callId: string }

function readStream(cursor: SseFrameCursor<Frame>, ctx: AgentContext, stream: PiAgentStream): Promise<Turn> {
  return driveFrameStream(cursor, stream, ctx, (frame) => {
    if (frame.type === "session") {
      ctx.session.capture(frame.sessionId);             // ← 第二步：回传的 id 写回，下一轮续接
      return;
    }
    if (frame.type === "approval_request") {            // ← 本步：应用停下等审批，存下现场
      ctx.session.hold<Pending>({ cursor, stream, callId: frame.toolCallId });
      return { pause: { id: frame.toolCallId, action: frame.toolName,
                        options: [{ id: "approve" }, { id: "deny" }] } };
      // driveFrameStream 收到 pause：补一条 input.requested、status 置 "waiting"、停止读流
    }
    if (frame.type === "server_error") return { fail: frame.message };
  });
}

export default defineAgent({
  name: "my-app",
  async send(input: TurnInput, ctx: AgentContext): Promise<Turn> {
    const held = ctx.session.take<Pending>();
    if (held) {                                         // ← t.respond("approve")：普通 send，先交裁决
      const approved = input.responses?.[0]?.optionId === "approve";  // 多请求并停时按 requestId 对位
      if (!approved) held.stream.markRejected(held.callId);
      await fetch(`${BASE_URL}/api/chat/approve`, { method: "POST", signal: ctx.signal,
        body: JSON.stringify({ toolUseId: held.callId, approved }) });
      return readStream(held.cursor, ctx, held.stream); //    再接着读同一条流，不重发请求
    }

    const res = await fetch(`${BASE_URL}/api/chat`, {   // ← transport：唯一真正手写的一段
      method: "POST",
      body: JSON.stringify({
        message: input.text,                            // ← 第一步：发消息
        model: ctx.model,                               // ← 第一步：experiment.model 转发
        sessionId: ctx.session.id,                      // ← 第二步：第一次发自动不带，之后自动带
      }),
      signal: ctx.signal,
    });
    return readStream(sseJsonFrames<Frame>(res.body!), ctx, fromPiAgentEvents());  // ← 第四步：官方转换器零映射
  },
});
```

不需要 HITL 的接口，删掉停轮现场相关的三处（`Pending`、`hold`、开头的 `take` 分支）即可，其余不变。停轮 / 回答 / 续跑的完整心智模型见 [HITL](/docs/zh/explanation/hitl)。

**这一步支持**：`t.parked()`、`t.requireInputRequest()`、`t.respond()` / `t.respondAll()`，以及 `calledTool(..., { status: "rejected" })` 的精确断言。

## 第六步：接上 OTel trace

应用已经埋了点（标准 OTel HTTP 服务端埋点即可）的话，接入分两半——一半是启动期配置，一半在 `send` 里。分清它们就是分清「不变的」和「每轮变的」。

**端点是启动期配置，不从 send 传。** [NiceEval](https://niceeval.com/) 的 OTLP 接收地址每次运行都一样，所以它不走 `ctx`：在 `niceeval.config.ts` 里把接收端口钉住，应用启动时把自己的 OTel exporter 指向这个固定 URL，之后跑多少次评估用例都不用再改：

```ts theme={null}
// niceeval.config.ts
import { defineConfig } from "niceeval";

export default defineConfig({
  telemetry: { port: 4318 },   // 接收器固定监听 http://localhost:4318/v1/traces
});
```

```bash theme={null}
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces node server.js
```

**send 里传的是本轮的 trace context，不是端点。** `ctx.telemetry.headers` 是运行器每轮新生成的 W3C `traceparent` 头——把它 spread 进请求，应用这一轮产生的 span 就精确挂到本轮的 trace 下，并发跑多条评估用例时归属不混。回到主线的 chat-app，`send` 里只多一行：

```ts theme={null}
    const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
      // ……其余同第四步，省略
      headers: { "content-type": "application/json", ...ctx.telemetry?.headers },  // ← 只多这一行
    }).then((r) => r.json());
```

`ctx.telemetry` 只在配置了 OTel 接入时出现，没配时 spread 一个 `undefined` 也安全，这行可以常驻。不带这个头 span 也能收到，但归属退化成时间窗口、该 agent 的轮次会降为串行——带上它是并发下归属准确的来源。

**这一步支持**：`niceeval view` 中按轮显示调用瀑布图，包括模型调用、工具执行、耗时与 token。断言仍读取前几步产生的事件；span 只用于瀑布图。接收器配置和 span 归属规则见 [OTel 接入](/docs/zh/tutorials/connect-otel)。

## 第七步：透传 experiment 的 flags（A/B 对比）

应用把变体暴露成可切换的配置后，experiment 声明 `flags`，运行器每轮经 `ctx.flags` 原样递给 `send`；Adapter 不解释它们的含义，只随请求转发——应用按参数切换变体：

```ts theme={null}
      // ……send 其余部分同前，省略
      body: JSON.stringify({
        messages,
        model: ctx.model,        // ← 第一步就转发的 experiment.model；这里一起列出
        flags: ctx.flags,      // ← experiment.flags 原样透传；没配时是 {}
      }),
```

```ts theme={null}
// experiments/concise.ts
import { defineExperiment } from "niceeval";
import chatApp from "../agents/chat-app.ts";

export default defineExperiment({
  agent: chatApp,
  model: "gpt-5.4",                      // ← 经 ctx.model 到 send
  flags: { promptVariant: "concise" },  // ← 每轮经 ctx.flags 到 send
});
```

两个 experiment 文件各声明一份 `flags`，`npx niceeval exp` 分别跑同一批评估用例，就是一组 A/B 对比。这是三档接入里的 Tier 3（要求应用配合暴露开关），投入与回报见 [Tier](/docs/zh/explanation/tier)；`flags` 与 `model`、`runs` 等其余 experiment 字段见[写实验](/docs/zh/tutorials/write-experiment)。

**这一步支持**：同一批评估用例跨变体的成绩对比。

## 对照：五个 t API 到 send 的形态

七步写完，回头看评估用例侧的五个驱动 API——它们到 `send` 只是同一个函数收到不同字段，没有第二个要实现的方法：

| 评估用例 API                  | Adapter 调用       | `send` 输入                                                                                                          |
| ------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------ |
| `t.send(text)`            | 一次 `send`        | `input.text`；`ctx.session` 是这条会话线的自有状态                                                                             |
| `t.sendFile(path, text?)` | 一次 `send`        | 同上，外加 `input.files`（base64 的 `InputFile[]`）；按应用接口的方式放进请求体，不支持多模态就忽略                                                |
| `t.newSession()`          | 不直接触发 `send`     | 开第二条会话线；该线随后第一次 `send` 拿到一份**全空的**状态——和整个评估用例的第一次 `send` 形态完全相同                                                    |
| `t.respond(...answers)`   | 一次**普通的** `send` | `input.text` = 回答文本；`input.responses` 逐请求一条 `{ requestId, optionId }`（自由文本回答则是 `{ requestId, text }`）；还是同一条线、同一份状态 |
| `t.respondAll(optionId)`  | 一次**普通的** `send` | 同上，每条待处理请求各一条回答；`optionId` 已在评估用例侧对每条请求校验过存在，写错直接抛，不会静默传给应用                                                        |

## 相关阅读

* [Adapter](/docs/zh/explanation/adapter) — `send` 的输入、输出、三个接入等级和能力来源。
* [接入你的 Agent](/docs/zh/tutorials/connect-your-agent) — 最小接入、参数传递与可选能力。
* [HITL](/docs/zh/explanation/hitl) — 停轮等人的完整概念：握手时序与两侧义务。
* [Drive](/docs/zh/explanation/drive) — 评估用例侧的 `t.send()`、`t.newSession()` 与 HITL 用法。
* [Assert](/docs/zh/explanation/assert) — 标准事件流驱动的完整断言词汇。
