目录

Base Agent Copilot Handler

Copilot Handler 表达的是一种 Agent 与 人交互的流程:

  1. Copilot Request 不是 http 的请求,指代模型生成的,需要用户确认的内容
  2. Copilot Response 是用户确认后的内容

为什么我们一直在强调 Copilot Handler 的重要性?是因为 Copilot 实现交互的同时,提供了一个时间点,执行记忆的提取。

这个项目的背景是做一个翻译 Agent,所以其核心工具是一个叫 Translate 的工具。通过这个工具我们将学习:

  1. 如何定义一个支持 Copilot Handler 的工具
  2. 如何实现 Copilot Handler

1. Translate 工具

Translate 工具需要完成的需求是这样: 从要翻译的整篇内容内,逐段进行翻译。

逐段的要求是实现精准翻译:

  1. 顺序翻译
  2. 不能重复翻译
  3. 不能遗漏翻译

对应 Translate 工具:

  1. Copilot Request: 模型创建的翻译单元和翻译结果
  2. Copilot Response: 用户确认的翻译结果

1.1 Translate 的定义

Tool Description:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 翻译工具使用规则

为待翻译的文档创建翻译单元。根据当前上下文的要求,每次选择适量文本进行翻译。

翻译时,请遵循用户的要求,包括目标语言、语气和术语。

- 翻译某个文件之前,请确保在当前上下文中已使用 `Read` 工具读取了对应 ID 的文件,以便你掌握最新的文件内容。
- `src_string` 在原文中必须唯一;如果不唯一,翻译将失败。你可以通过扩大 `src_string` 的范围来保证唯一性。
- `src_string` 只应包含当前单元的内容,不得包含之前已翻译单元的内容,不能包含已经翻译过的内容。
- `translate_string``src_string` 的初步译文,应尽可能准确,以减少后续人工校对的工作量。
- 返回的 `translated_string` 是经过人工审核后的最终译文。
- `status` 表示人工审核状态,可为 `approve`(通过)、`reject`(拒绝)或 `refined`(用户手动调整)。
- 如果 `reject` 是因为 `src_string` 在原文中不存在,请核对所选单元,确保空格和换行符匹配。
- 如果 `reject` 是因为 `src_string` 不唯一,请逐步扩大范围,直到找到唯一匹配。

注意: src_string 必须唯一,是为了定位翻译已经翻译的位置。

1.2 System Prompt 约束

但是约束翻译行为不仅仅是在 Translate 的定义上,system prompt 也需要约束翻译行为。

1
2
3
4
- 翻译前,务必使用“Read”工具获取文件的最新内容。
- 使用“Translate”工具创建翻译单元。每次翻译一个段落,并保持段落完整性(例如在Markdown中按标题或列表进行分割),每个翻译后的段落长度不超过300个字符。
- 从第一行开始翻译至文件末尾,不得遗漏任何内容。
- “Translate”工具返回经用户验证的翻译结果。“Approve”或“Refine”状态表示该单元已完成;“Reject”状态表示需要重新翻译。

1.3 工具校验 src_string 结果

Translate 工具里会校验 src_string 是否在原文中,以及出现的次数。校验的结果对应工具提示词 reject 的两个原因。

但是代码里面并没有对 src_string 是否包含翻译过的内容,是否跳过内容没有翻译没有校验。这两部分完全交给了 Translate 定义的提示词和 system prompt 来约束。我感觉这一部分其实也应该放在 Translate 工具的校验里。(这一部分其实做了校验,对应源代码里的 getContextualDisplay。但是没有作为错误返回,让模型重新生成。)

1.4 总结

至此我们对支持 Copilot Handler 的工具的实现做一个总结:

  1. 模型输入什么,包括对模型输入的限定规则
  2. 用户反馈什么
  3. 模型对用户反馈的处理
  4. 系统如何使用工具
  5. 工具的实现要校验模型输入是否合法,输入校验是否合法可以在工具内,也可以在应用侧,因为应用侧也能拿到 Copilot Request。无论在哪里实现,最终校验失败的反馈都是 Copilot Response 提交给模型

2. 如何实现 Copilot

Copilot 的实现有多重方法,并且依赖 Agent App 的形态。我们 Copilot 的实现分成两种:

  1. Tool 阻塞等待
  2. Tool 不阻塞等待,Tool 的执行分为两个阶段在两次 Agent Loop 中完成。下称多轮实现

第一种方式更加符合直觉,第二种不好理解,但是实现更优,其实现方式就是在 AgentLoop 内将 Tool 的执行从两阶段变成三阶段:

  1. tool call
  2. tool exec 返回 Copilot Request。第一次 Loop 结束,此时 actor=“user”。
  3. 第二次 Loop,Copilot Response 作为参数传给 tool exec。 tool 执行完成返回 result。

2.1 Tool 阻塞等待

Tool 阻塞等待的实现难点,如何分离 Tool 阻塞和用户确认的逻辑。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export const useAgent = () => {

  const [copilotRequest, setCopilotRequest] = useState<CopilotRequest | null>(null);
  const copilotResolverRef = useRef<
    (value: CopilotResponse | PromiseLike<CopilotResponse>) => void | null
  >(null);

  const agentLoopRef = useRef<AgentLoop | null>(null);

  const initAgentLoop = useCallback(async () => {

      agentLoopRef.current = new AgentLoop({
        abortSignal: abortController.current.signal,
        copilotHandler: (req) => {
          setCopilotRequest(req);

          return new Promise((resolve) => {
            copilotResolverRef.current = resolve;
          });
        },
      });

    }
  };

工具的实现里面 await copilotHandler(CopilotRequest)

  1. Agent App 可以拿到 Copilot Request
  2. Tool 会阻塞等待,直到用户交互完成,copilotResolverRef 被 resolve
  3. resolve 返回 Copilot Response
  4. Tool 拿到 Copilot Response 后,继续执行,然后返回,Loop 继续。

3. API 形态的 Agent

在继续介绍 Tool 的多轮实现之前。我们先来对比一下 TUI 和 API 两种形态下 Agent 通信的差异。在这里去讲解这个这个问题,可以让我们更好的理解这个差异,以及 Tool 多轮调用的实现。

3.1 TUI VS API

TUI API
/images/aigc/base_agent/cli.png /images/aigc/base_agent/api.png

3.2 TUI 的通信过程

TUI终端界面与Agent核心控制逻辑运行在同一个进程,二者通过普通函数调用交互,数据直接在内存交换。

  1. 执行流程
  • 每一轮循环由TUI调用agent‑core的next函数;next返回当前角色actor、待处理工具调用给TUI,用于界面渲染或触发下一轮逻辑;
  • 调用完成后TUI再调用get message,获取完整消息用于UI展示。
  1. 消息存储
  • agent‑core内部维护context管理器,保存两套消息:完整对话消息、经过压缩后实际传给大模型的消息;
  • TUI通过get message拿到context内的全量消息,渲染消息列表,按消息类型做样式展示,这是CLI形态的实现逻辑。
  1. 两大关键特点
  • 同进程内存交互,无需序列化,每轮取回全量消息成本低、效率高;
  • agent‑core生命周期与进程绑定,进程不销毁实例就持续存活,可反复调用next推进Agent循环。

3.3 API 的通信过程

Web UI + API 前后端分离架构:

  1. AgentLoop 和 Agent App 之间要使用网络来传输。走网络传输,意味着不能每次循环全量获取消息
  2. Agent Core 的生命周期不在和进程绑定,可以有两种处理方式:
  • AgentCore生命周期绑定单次HTTP请求,请求结束实例就销毁;
  • 维护一个有状态的 Agent Core。因为不确定 Web UI 是否存储,就需要处理客户端存活检测、状态管理。

更加生产可用的方式是让 AgentCore 跟随请求生命周期,通过架构设计去解决由此带来的各类问题。

Copilot实时交互适配难点

  • CLI模式下,所有的交互都在一个进程内,我们可以让 Tool 无限阻塞等待用户反馈。
  • Web模式下:
    • 无法在单个API请求内无限等待客户端返回。
    • Copilot 的交互过程是,server —> copilot request -> ui -> copilot response -> server,所以 sse 不行
    • 即便用 WebSocket 双向通道模拟 await Promise 的写法,也会显著提升复杂度,需要维护请求内有状态Promise与额外双向链路。

这里需要特殊的技巧(Promise.race)融合Agent主逻辑与Copilot交互逻辑,后续会展开讲解。下面我们就会讲解 Promise.race 的实现,作为对比就能体现多轮实现的优势。

Session会话管理

  • CLI:客户端与AgentCore一一对应,全局仅有一个实例。
  • Web+API:服务端对接多个Web客户端,一对多关系;多个用户同时访问,后端同时存在多个session,每个session保存独立的AgentCore与上下文context。

3.4 技术选型

Agent Core 放在前端还是后端

  1. 与大模型交互的安全性 agent core 是实际和大模型进行交互的模块,部署在后端更加合理、安全。

也可以由后端做路由转发API,前端间接调用大模型。 但该方案依旧会暴露所使用的模型、原始调用信息。 转发接口会变成通用推理API,后端接口需要增加多层防护,防止被滥用。

  1. 商用场景,保护核心业务逻辑 面向商用级别的 Agent 产品,希望 agent core 核心业务逻辑对用户不可见。 用户只看到最终结果,不希望暴露每一步内部调用过程。 一旦逻辑暴露,agent core 的实现细节容易被逆向分析。

agent core 部署后端,仅向UI输出运行结果,可以更好保护内部逻辑。

  1. 上下文 context 状态管理与持久化 agent core 内部持有完整 context,包含两部分数据:
  • 当前 session 对话全部 message
  • 经过压缩处理、实际发给AI的消息

做具备持久化能力的正式应用,无论 agent core 放前端还是后端,后端都必须保存一份 context。

如果 agent core 放在前端: 需要由前端主动完成消息上报。 把核心有状态逻辑交给UI控制并不稳定:

  • UI上报过程中断,后端无法补齐消息,造成消息残缺
  • 前端消息非幂等上报,会造成后端记录错乱

因此将有状态的核心 context 管理放在后端。

Agent 使用短生命周期设计

第二个决策,将 agent core 设置为短生命周期。

agent core 的整个生命周期运行在单次 API 请求内部:

  • 请求开始:新建一个 agent core 对象
  • 请求结束:agent core 对象随之销毁

因此每次重建 agent core 对象时,都需要传入本次对话历史消息,以此重建状态,执行后续逻辑,最后返回结果。

这就是无状态 agent core 的优势:所有状态全部外置,仅需要在一次 API 请求内短暂存活即可。

agent core 自身保持无状态,那么多次 API 请求之间(第1次、第2次、第3次调用),需要专门组件维护多轮调用之间共享的对话消息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 1. 从请求的 sessionId 查询上一次的对话消息
const session = sessionId
  ? sessionManager.getSession(sessionId)
  : sessionManager.createSession();

if (!session) {
  return new Response(`Session "${sessionId}" not found`, { status: 404 });
}

// 2. 重建状态
const agentLoop = new AgentLoop({
  abortSignal: req.signal,
  copilotHandler: async (copilotReq) => { /* ... */ },
},
  session.messages
);

API 设计

相比 CLI 模式,Web 前后端分离架构对 next 接口返回做了两处关键增强:

  1. 返回本轮增量新消息 new message,不再每次请求拉取全量消息
  2. 每条对话对应唯一 sessionId,随 next 接口返回

即使 next 请求意外中断、响应丢失,前端也可通过 sessionId 重新拉取全量消息,恢复对话状态,保证会话稳定。

4. Promise.race 实现 Copilot Handler

4.1 实现原理

Promise.race 是把一次 AgentLoop 的执行拆成了两段去执行:

  1. 第一步 Copilot Handler 被调用,返回 Copilot Request,此时AgentLoop 就被丢弃了。丢弃的方法就是 Promise.race 里协程自动销毁。
  2. 第二步 用户带着 Copilot Response 再次请求,这个时候 Copilot 工具正常返回,AgentLoop 正常执行。

4.2 执行过程解读

逻辑比较绕,我们对着代码来看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
export async function POST(req: Request) {
  const {
    copilotResponse,
    sessionId,
    userInput,
  }: {
    sessionId?: string;
    userInput?: string;
    copilotResponse?: CopilotResponse;
  } = await req.json();

  const session = sessionId
    ? sessionManager.getSession(sessionId)
    : sessionManager.createSession();

  if (!session) {
    return new Response(`Session "${sessionId}" not found`, { status: 404 });
  }

  const { resolve: copilotResolver, promise: copilotPromise } =
    Promise.withResolvers<AgentResponse>();

  const agentLoop = new AgentLoop(
    {
      abortSignal: req.signal,
      copilotHandler: async (copilotReq) => {
        if (copilotResponse) {
          return copilotResponse;
        }

        copilotResolver({
          type: "copilot",
          result: copilotReq,
        });

        throw new Error("abort");
      },
    },
    session.messages
  );
    //  
    const res = await Promise.race([
    copilotPromise,
    agentLoop.next().then<AgentResponse>((result) => ({
      type: "normal",
      result,
    })),
  ]);

  if (res.type === "normal") {
    sessionManager.addMessages(session.id, res.result.messages);
  }

  return new Response(
    JSON.stringify({
      sessionId: session.id,
      agentResponse: res,
    }),
    { status: 200 }
  );
}

Promise.race([p1, p2]):传入多个 Promise,返回一个新 Promise。哪个 Promise 最先完成(resolve /reject),就采用它的结果,其余 Promise 会继续在后台执行,但结果会被忽略。

所以这个 Post 请求会返回两种 Response:

  1. copilotPromise 被 resolve 时,返回 Copilot Request
  2. agentLoop.next() 被 resolve 时,返回正常 Response

一次完整的 Copilot Handler 执行流程如下:

第一次 Post,没有 Copilot Response

  1. AgentLoop.next() 执行工具调用,工具内部 await copilotHandler(copilotReq)
  2. 此时 copilotResolver 被调用,copilotPromise 被 resolve,res 返回 Copilot Request。
  3. copilotResolver 被调用之后,执行 throw new Error("abort"); AgentLoop.next() 直接异常退出
  4. Promise.race 中 copilotPromise “胜出”,AgentLoop 异常退出。
  5. post 请求返回 Copilot Request。

第二次 Post,有 Copilot Response

  1. AgentLoop.next() 执行工具调用,工具内部 await copilotHandler(copilotReq)
  2. copilotHandler 返回 Copilot Response,工具正常执行返回
  3. Promise.race 中 agentLoop.next() “胜出”,copilotPromise 会一直阻塞,AgentLoop 正常执行。
  4. post 请求返回正常 Response。

4.3 Promise.race 问题

  1. 打破了 AgentLoop 每次执行一轮的语义。这是 Promise.race 和多轮调用的核心区别,在多轮调用里,AgentLoop 是正常返回的。
  2. 每次只能处理一个 Copilot Handler
  3. Cli 和 Web 模式下 AgentLoop 实现存在差异,他们需要传入不同的 Copilot Handler

5. 多轮调用

多轮调用里,AgentLoop 内将 Tool 的执行从两阶段变成三阶段:

  1. tool call
  2. tool exec 返回 Copilot Request 作为结果。第一次 Loop 结束,此时 actor=“user”。
  3. 第二次 Loop 发起,用户传入 Copilot Response,其作为参数传给 tool exec。 tool 执行完成返回 result。

因此 AgentLoop 就需要维护已经发出的 Copilot Request,并在接受到 Copilot Response 后,将它们匹配,并作为传递给 Tool。

接下来我们将分成三个部分讲解多轮调用的实现:

  1. Tool 工具的实现
  2. AgentLoop 改造

5.1 Translate 实现

Translate:

  1. 接受 CopilotResponse 作为参数
  2. 返回两种结果:
    • CopilotRequestResult: 第一次调用,返回 Copilot Request
    • ToolResult: 第二次调用,返回正常 Response
  3. 两种响应结果通过 type 字段区分,exector 根据 type 区分是不是 Copilot 的请求。
  4. CopilotRequest 包含 tool 调用信息,Copilot Response 会带上调用信息,用于与 Tool 匹配。AgentLoop 里面我们就能看到这个匹配过程。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class ToolCallOptions(StrictModel):
    name: str
    call_id: str = Field(alias="callId")


class CopilotRequest(StrictModel):
    tool: ToolCallOptions
    src_string: str
    translate_string: str
    file_id: str

@dataclass(frozen=True, slots=True)
class CopilotRequestResult:
    payload: CopilotRequest
    type: Literal["copilot-request"] = "copilot-request"


@dataclass(frozen=True, slots=True)
class ToolResult:
    payload: BaseModel | JsonValue
    type: Literal["tool-result"] = "tool-result"

@dataclass(slots=True)
class TranslateExecutor:
    writer: TranslateWriter

    async def __call__(
        self,
        input_data: Mapping[str, Any],
        context: ToolExecutionContext,
        copilot_response: CopilotResponse | None = None,
    ) -> CopilotRequestResult | ToolResult:
        try:
            input_value = TranslateInput.model_validate(input_data)
        except ValidationError as exc:
            if any(
                error["type"] == "string_too_long"
                and error["loc"] == ("src_string",)
                for error in exc.errors()
            ):
                return ToolResult(
                    payload=TranslateOutput(
                        translated_string="",
                        status="reject",
                        reason="Source string exceeds maximum length of 300 characters",
                    )
                )
            raise

        request = CopilotRequest(
            tool=ToolCallOptions(name=context.name, call_id=context.call_id),
            file_id=input_value.file_id,
            src_string=input_value.src_string,
            translate_string=input_value.translate_string,
        )
        if copilot_response is None:
            return CopilotRequestResult(payload=request)

        if copilot_response.tool != request.tool:
            raise ValueError("Copilot response does not match the pending tool call")

        if copilot_response.status in ("approve", "refined"):
            await self.writer(
                TranslateWrite(
                    translated_string=copilot_response.translated_string,
                    file_id=input_value.file_id,
                )
            )

        if copilot_response.status != "approve" and context.memory is not None:
            await context.memory.extract_memory(req=request, res=copilot_response)

        output = TranslateOutput(
            translated_string=copilot_response.translated_string,
            status=copilot_response.status,
            reason=copilot_response.reason,
        )
        return ToolResult(payload=output)

5.2 AgentLoop 改造

维护 Copilot Request

AgentLoop 需要再 Context 维护已经收到的 Copilot Response。Copilot Request 是不需要维护的,因为他们保存模型发起的 tool call message 内。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class AgentLoop:
    """A single-step agent state machine built directly on Pydantic AI's model layer."""

    def __init__(
        self,
        options: AgentLoopOptions | None = None,
        messages: Sequence[ModelMessage] = (),
    ) -> None:
        self.options = options or AgentLoopOptions()
        self.context = Context(messages)
        self.model = self.options.model or models.translator
        self.tool_defs = self._normalize_tool_defs(self.options.tool_defs)
        self.tool_executors = dict(self.options.tool_executors)


    async def user_input(self, messages: Sequence[ModelRequest] | str) -> None:
        if isinstance(messages, str):
            self.context.add_messages([ModelRequest(parts=[UserPromptPart(messages)])])
        else:
            self.context.add_messages(messages)

    async def add_copilot_responses(self, responses: Sequence[CopilotResponse]) -> None:
        self.context.add_copilot_responses(responses)

class Context:
    def __init__(self, messages: Sequence[ModelMessage] = ()) -> None:
        self._messages: list[ModelMessage] = list(messages)
        self._copilot_responses: list[CopilotResponse] = []
        self._active_messages: list[ModelMessage] = list(messages)

    def add_messages(self, messages: Sequence[ModelMessage]) -> None:
        self._messages.extend(messages)
        self._active_messages.extend(messages)

    def add_copilot_responses(self, responses: Sequence[CopilotResponse]) -> None:
        self._copilot_responses.extend(responses)

    def get_messages(self) -> list[ModelMessage]:
        return list(self._messages)

    def get_copilot_responses(self, tool_call_ids: Sequence[str]) -> list[CopilotResponse]:
        ids = set(tool_call_ids)
        return [response for response in self._copilot_responses if response.tool.call_id in ids]

AgentLoop 执行

在 AgentLoop next 的一轮执行里:

  1. 返回的结果单独保存了 Copilot Request
  2. 工具的执行会先匹配用户已经反馈的 Copilot Response,并将匹配的参数传递给 Tool Executor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class AgentLoop:
    async def next(self) -> AgentStepResult:
        result = await self._next()
        self.context.add_messages(result.messages)
        return AgentStepResult(
            actor=result.actor,
            unprocessed_tool_calls=self.get_unprocessed_tool_calls(),
            copilot_requests=result.copilot_requests,
            messages=result.messages,
            finish_reason=result.finish_reason,
        )

    async def _next(self) -> _NextResult:
        self._check_cancelled()
        model_messages = self.context.to_model_messages()
        if not model_messages:
            return _NextResult(messages=[], copilot_requests=[], actor="user")
        # 1. 执行未完成的 tool call
        pending_calls = self.get_unprocessed_tool_calls()
        if pending_calls:
            # 2. 查询哪些未完成的 tool call,用户已经反馈了 Copilot Response
            response_map = {
                response.tool.call_id: response
                for response in self.context.get_copilot_responses(
                    [call.tool_call_id for call in pending_calls]
                )
            }
            executed = await asyncio.gather(
                *(
                    # 3. 执行 tool call,传递匹配的 Copilot Response
                    self.execute_tool(call, response_map.get(call.tool_call_id))
                    for call in pending_calls
                )
            )
            tool_returns: list[ToolReturnPart] = []
            copilot_requests: list[CopilotRequest] = []
            for result in executed:
                # 4. 区分 tool call 正常返回和 Copilot Request
                if isinstance(result, CopilotRequest):
                    copilot_requests.append(result)
                else:
                    tool_returns.append(result)

            messages: list[ModelMessage] = []
            # 5. 处理 Copilot Request,直接返回
            if copilot_requests:
                # Keep copilot requests in a separate iteration, matching the
                # TypeScript loop. Tool returns are not committed until every
                # pending call can complete without human input.
                return _NextResult(
                    messages=[],
                    copilot_requests=copilot_requests,
                    actor="agent",
                )

            return _NextResult(
                messages=[ModelRequest(parts=tool_returns)],
                copilot_requests=[],
                actor="agent",
            )
        # 5. 没有未完成的 tool call,本轮发起新的模型调用
        request_parameters = ModelRequestParameters(
            function_tools=list(self.tool_defs.values()),
            output_mode="text",
        )
        system = system_workflow(
            current_memory=self.options.memory.provide_memory() if self.options.memory else "",
            skills_prompt=self.options.skills_prompt,
        )
        request_messages: list[ModelMessage] = [
            ModelRequest(parts=[SystemPromptPart(system)]),
            *model_messages,
        ]
        wire_messages = self.model.prepare_messages(request_messages, request_parameters)
        response = await self.model.request(
            wire_messages,
            self.options.model_settings,
            request_parameters,
        )
        self._check_cancelled()

        actor: NextActor = "agent" if response.tool_calls else "user"
        return _NextResult(
            messages=[response],
            copilot_requests=[],
            actor=actor,
            finish_reason=response.finish_reason,
        )

5.3 Agent API

API 接口内,根据用户输入的 userInput 或 CopilotResponses,更新 Session 并执行 AgentLoop 的 next 方法。

CopilotResponses 作为新的状态,需要被 Session 维护,用于会话的恢复。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
export async function POST(req: Request) {
  try {
    const {
      copilotResponses,
      sessionId,
      userInput,
    }: {
      sessionId?: string;
      userInput?: string;
      copilotResponses?: CopilotResponse[];
    } = await req.json();

    const { env } = getCloudflareContext();
    const storage = new D1SessionStorage(env.DB);
    const sessionManager = new SessionManager(storage);

    const session = sessionId
      ? await sessionManager.getSession(sessionId)
      : await sessionManager.createSession();

    if (!session) {
      return new Response(`Session "${sessionId}" not found`, { status: 404 });
    }

    const agentLoop = new AgentLoop(
      {
        abortSignal: req.signal,
      },
      session.messages,
    );

    if (userInput) {
      const userMessages: UserModelMessage[] = [
        {
          role: "user",
          content: [
            {
              type: "text",
              text: userInput,
            },
          ],
        },
      ];
      await sessionManager.addMessages(session.id, userMessages);
      await agentLoop.userInput(userMessages);
    }

    if (copilotResponses) {
      await sessionManager.addCopilotResponses(session.id, copilotResponses);
      await agentLoop.addCopilotResponses(copilotResponses);
    }

    const res = await agentLoop.next();

    await sessionManager.addMessages(session.id, res.messages);

    return new Response(
      JSON.stringify({
        sessionId: session.id,
        agentResponse: res,
      }),
      { status: 200 },
    );
  } catch (error) {
    return new Response(
      JSON.stringify({
        error: error instanceof Error ? error.message : "Unknown error",
      }),
      { status: 500 },
    );
  }
}

5.4 Agent UI

UI 实现的 doNext 方法,驱动 AgentLoop 的执行。这个逻辑与 TUI 是一样的。doNext 会在两个地方 break:

  1. agentResponse.copilotRequests.length > 0,说明还有 Copilot Request 未处理,需要等待用户反馈。
  2. agentResponse.actor === "user",说明没有未处理的 tool call,本轮发起新的模型调用。

这里跟我之前理解的不一样,存在 Copilot Request的时候,并没有把 actor 设置为 “user”。我个人感觉,判断 agentResponse.copilotRequests.length > 0 没问题,但是 actor 应该改成 “user”

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
const doNext = async (
    params:
      | { type: "userInput"; input: string }
      | { type: "copilot"; responses: CopilotResponse[] },
  ) => {
    setCurrentActor("agent");

    let round = 1;

    while (runningRef.current) {
      try {
        const body = {
          sessionId: sessionIdRef.current,
        };
        if (round === 1) {
          Object.assign(body, {
            userInput: params.type === "userInput" ? params.input : undefined,
            copilotResponses:
              params.type === "copilot" ? params.responses : undefined,
          });
        }

        const { sessionId, agentResponse } = await fetch("/api/next", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(body),
          signal: abortController.current?.signal,
        }).then(
          (res) =>
            res.json() as Promise<{
              sessionId: string;
              agentResponse: AgentResponse;
            }>,
        );

        round++;

        sessionIdRef.current = sessionId;
        // 1. 根据服务端结果同步客户端状态
        if (
          agentResponse.copilotRequests &&
          agentResponse.copilotRequests.length > 0
        ) {
          setCopilotRequests(agentResponse.copilotRequests);
          break;
        } else {
          setCurrentActor(agentResponse.actor);

          addMessages(agentResponse.messages);

          setUnprocessedToolCalls(agentResponse.unprocessedToolCalls);

          if (agentResponse.actor === "user") {
            break;
          }
        }
      } catch (error) {
        const isAbortError =
          error instanceof Error && error.name === "AbortError";
        if (!isAbortError) {
          console.error("Error in agent loop:", error);
        }
        runningRef.current = false;
        setCurrentActor("user");
        break;
      }
    }
  };

5.5 轮动调用处理流程:

至此,我们就可以完成来看多轮调用的完整流程:

第一次调用,没有 Copilot Response

  1. AgentLoop.next() 执行工具调用,工具内部返回 Copilot Request。
  2. post 请求返回 Copilot Request
  3. UI 处理 Copilot Request,等待用户反馈。

第二次调用,有 Copilot Response

  1. Post 请求包含 Copilot Response,AgentLoop.next() 匹配到 Copilot Response 之后,传递给 Tool,工具正常执行返回
  2. AgentLoop 进入到正常的轮次,进行响应

整个逻辑清晰,简单,支持同事处理多个 Copilot Request。