Copilot Handler 表达的是一种 Agent 与 人交互的流程:
- Copilot Request 不是 http 的请求,指代模型生成的,需要用户确认的内容
- Copilot Response 是用户确认后的内容
为什么我们一直在强调 Copilot Handler 的重要性?是因为 Copilot 实现交互的同时,提供了一个时间点,执行记忆的提取。
这个项目的背景是做一个翻译 Agent,所以其核心工具是一个叫 Translate 的工具。通过这个工具我们将学习:
- 如何定义一个支持 Copilot Handler 的工具
- 如何实现 Copilot Handler
1. Translate 工具
Translate 工具需要完成的需求是这样: 从要翻译的整篇内容内,逐段进行翻译。
逐段的要求是实现精准翻译:
- 顺序翻译
- 不能重复翻译
- 不能遗漏翻译
对应 Translate 工具:
- Copilot Request: 模型创建的翻译单元和翻译结果
- 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 的工具的实现做一个总结:
- 模型输入什么,包括对模型输入的限定规则
- 用户反馈什么
- 模型对用户反馈的处理
- 系统如何使用工具
- 工具的实现要校验模型输入是否合法,输入校验是否合法可以在工具内,也可以在应用侧,因为应用侧也能拿到 Copilot Request。无论在哪里实现,最终校验失败的反馈都是 Copilot Response 提交给模型
2. 如何实现 Copilot
Copilot 的实现有多重方法,并且依赖 Agent App 的形态。我们 Copilot 的实现分成两种:
- Tool 阻塞等待
- Tool 不阻塞等待,Tool 的执行分为两个阶段在两次 Agent Loop 中完成。下称多轮实现
第一种方式更加符合直觉,第二种不好理解,但是实现更优,其实现方式就是在 AgentLoop 内将 Tool 的执行从两阶段变成三阶段:
- tool call
- tool exec 返回 Copilot Request。第一次 Loop 结束,此时 actor=“user”。
- 第二次 Loop,Copilot Response 作为参数传给 tool exec。 tool 执行完成返回 result。
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)
- Agent App 可以拿到 Copilot Request
- Tool 会阻塞等待,直到用户交互完成,copilotResolverRef 被 resolve
- resolve 返回 Copilot Response
- Tool 拿到 Copilot Response 后,继续执行,然后返回,Loop 继续。
3. API 形态的 Agent
在继续介绍 Tool 的多轮实现之前。我们先来对比一下 TUI 和 API 两种形态下 Agent 通信的差异。在这里去讲解这个这个问题,可以让我们更好的理解这个差异,以及 Tool 多轮调用的实现。
3.1 TUI VS API
| TUI |
API |
 |
 |
3.2 TUI 的通信过程
TUI终端界面与Agent核心控制逻辑运行在同一个进程,二者通过普通函数调用交互,数据直接在内存交换。
- 执行流程
- 每一轮循环由TUI调用agent‑core的
next函数;next返回当前角色actor、待处理工具调用给TUI,用于界面渲染或触发下一轮逻辑;
- 调用完成后TUI再调用
get message,获取完整消息用于UI展示。
- 消息存储
- agent‑core内部维护context管理器,保存两套消息:完整对话消息、经过压缩后实际传给大模型的消息;
- TUI通过
get message拿到context内的全量消息,渲染消息列表,按消息类型做样式展示,这是CLI形态的实现逻辑。
- 两大关键特点
- 同进程内存交互,无需序列化,每轮取回全量消息成本低、效率高;
- agent‑core生命周期与进程绑定,进程不销毁实例就持续存活,可反复调用
next推进Agent循环。
3.3 API 的通信过程
Web UI + API 前后端分离架构:
- AgentLoop 和 Agent App 之间要使用网络来传输。走网络传输,意味着不能每次循环全量获取消息
- 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 放在前端还是后端
- 与大模型交互的安全性
agent core 是实际和大模型进行交互的模块,部署在后端更加合理、安全。
也可以由后端做路由转发API,前端间接调用大模型。
但该方案依旧会暴露所使用的模型、原始调用信息。
转发接口会变成通用推理API,后端接口需要增加多层防护,防止被滥用。
- 商用场景,保护核心业务逻辑
面向商用级别的 Agent 产品,希望 agent core 核心业务逻辑对用户不可见。
用户只看到最终结果,不希望暴露每一步内部调用过程。
一旦逻辑暴露,agent core 的实现细节容易被逆向分析。
agent core 部署后端,仅向UI输出运行结果,可以更好保护内部逻辑。
- 上下文 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 接口返回做了两处关键增强:
- 返回本轮增量新消息 new message,不再每次请求拉取全量消息。
- 每条对话对应唯一 sessionId,随
next 接口返回
即使 next 请求意外中断、响应丢失,前端也可通过 sessionId 重新拉取全量消息,恢复对话状态,保证会话稳定。
4. Promise.race 实现 Copilot Handler
4.1 实现原理
Promise.race 是把一次 AgentLoop 的执行拆成了两段去执行:
- 第一步 Copilot Handler 被调用,返回 Copilot Request,此时AgentLoop 就被丢弃了。丢弃的方法就是 Promise.race 里协程自动销毁。
- 第二步 用户带着 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:
- copilotPromise 被 resolve 时,返回 Copilot Request
- agentLoop.next() 被 resolve 时,返回正常 Response
一次完整的 Copilot Handler 执行流程如下:
第一次 Post,没有 Copilot Response
- AgentLoop.next() 执行工具调用,工具内部
await copilotHandler(copilotReq)。
- 此时 copilotResolver 被调用,copilotPromise 被 resolve,res 返回 Copilot Request。
- copilotResolver 被调用之后,执行
throw new Error("abort"); AgentLoop.next() 直接异常退出
- Promise.race 中 copilotPromise “胜出”,AgentLoop 异常退出。
- post 请求返回 Copilot Request。
第二次 Post,有 Copilot Response
- AgentLoop.next() 执行工具调用,工具内部
await copilotHandler(copilotReq)
- copilotHandler 返回 Copilot Response,工具正常执行返回
- Promise.race 中 agentLoop.next() “胜出”,copilotPromise 会一直阻塞,AgentLoop 正常执行。
- post 请求返回正常 Response。
4.3 Promise.race 问题
- 打破了 AgentLoop 每次执行一轮的语义。这是 Promise.race 和多轮调用的核心区别,在多轮调用里,AgentLoop 是正常返回的。
- 每次只能处理一个 Copilot Handler
- Cli 和 Web 模式下 AgentLoop 实现存在差异,他们需要传入不同的 Copilot Handler
5. 多轮调用
多轮调用里,AgentLoop 内将 Tool 的执行从两阶段变成三阶段:
- tool call
- tool exec 返回 Copilot Request 作为结果。第一次 Loop 结束,此时 actor=“user”。
- 第二次 Loop 发起,用户传入 Copilot Response,其作为参数传给 tool exec。 tool 执行完成返回 result。
因此 AgentLoop 就需要维护已经发出的 Copilot Request,并在接受到 Copilot Response 后,将它们匹配,并作为传递给 Tool。
接下来我们将分成三个部分讲解多轮调用的实现:
- Tool 工具的实现
- AgentLoop 改造
5.1 Translate 实现
Translate:
- 接受 CopilotResponse 作为参数
- 返回两种结果:
- CopilotRequestResult: 第一次调用,返回 Copilot Request
- ToolResult: 第二次调用,返回正常 Response
- 两种响应结果通过 type 字段区分,exector 根据 type 区分是不是 Copilot 的请求。
- 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 的一轮执行里:
- 返回的结果单独保存了 Copilot Request
- 工具的执行会先匹配用户已经反馈的 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:
agentResponse.copilotRequests.length > 0,说明还有 Copilot Request 未处理,需要等待用户反馈。
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
- AgentLoop.next() 执行工具调用,工具内部返回 Copilot Request。
- post 请求返回 Copilot Request
- UI 处理 Copilot Request,等待用户反馈。
第二次调用,有 Copilot Response
- Post 请求包含 Copilot Response,AgentLoop.next() 匹配到 Copilot Response 之后,传递给 Tool,工具正常执行返回
- AgentLoop 进入到正常的轮次,进行响应
整个逻辑清晰,简单,支持同事处理多个 Copilot Request。