数据截至 (上游 commit 1386791923ca)
对外形态:computer_* 工具、本地/RDP 两种后端
30 秒导读: 前面几章讲的是 computer-use 的「内功」——主循环、视觉定位、模型、设备执行。这一章讲外功:这些能力怎么被打包成一个能用的产品面。核心就两件事:(1) 一套
computer_*工具定义,同时被 MCP server 和命令行复用;(2) 调用方只要传不传host,同一套工具背后就在本地桌面和 RDP 远程 Windows 两种后端之间切换。
本章聚焦装配与形态边界。真正的循环 / 定位 / 模型 / 设备执行,分别见 01-agent-loop、02-vision-grounding、03-model-family-adapters、04-device-and-action-space。
1. 这是什么(零基础也能懂)
一句话定义: 这是 Midscene 把「用 AI 操作一整台电脑」的能力,对外封装成工具的那一层。
它解决的问题是:内核已经能截图、能定位、能点击了,但外部世界(一个 AI agent、一个终端用户)怎么调用它?
Midscene 的答案是把每个能力做成一个工具(tool),名字统一带 computer_ 前缀:
| 工具 | 干什么 |
|---|---|
computer_connect | 连上一台电脑桌面(本地或远程),回传第一帧截图 |
computer_disconnect | 断开、释放资源 |
computer_list_displays | 列出可用的显示器 |
computer_tap / computer_type / … | 由「动作空间」自动生成的操作工具(见 04) |
这套工具有两个出口,但定义只有一份:
- MCP 出口:工具名保持
computer_connect这样的全名,供支持 MCP(Model Context Protocol,AI 与外部工具的对接协议)的 agent 直接调用。 - CLI 出口:同一份定义,把
computer_前缀剥掉,变成midscene-computer connect这样的子命令,供人在终端里跑。
一句话直觉: 把它想成一个「电脑遥控器」的按钮面板。面板上的按钮(工具)是同一批;你可以通过程序(MCP)去按,也可以在终端(CLI)里按;而遥控器背后连的那台电脑,可以是你面前这台(local),也可以是机房里一台 Windows(RDP)。
用起来什么样: 命令行里的一次连接大致长这样。
# 连本地主显示器,然后点一下某处
midscene-computer connect
midscene-computer tap --prompt "登录按钮"
# 连一台远程 Windows(给了 host,就切到 RDP 模式)
midscene-computer connect --host 10.0.0.5 --username alice
本节不出现底层细节。记住一句话就够:一份工具定义,两个出口(MCP/CLI),两种后端(本地/RDP)。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上往下是「调用 → 装配 → 落到真实桌面」;中间的 ComputerMidsceneTools 是唯一的汇聚点,左右两条分叉只在最后一步(接哪种设备)才分开。
一份工具定义 ToolDefinition[]
│
┌─────────────────┴─────────────────┐
MCP server CLI 入口
(工具名 computer_*) runToolsCLI + stripPrefix
'computer_' → 子命令 connect/tap/…
└─────────────────┬─────────────────┘
ComputerMidsceneTools
preparePlatformTools() + 动作工具
│
│ ensureAgent(initArgs)
│ ——— 传了 host 吗?———
┌───────────┴───────────┐
否 = local 是 = rdp
│ │
agentForComputer agentForRDPComputer
│ │
ComputerDevice RDPDevice
(本机桌面) (rdp-helper 二进制 → 远程 Windows)
└───────────┬───────────┘
ComputerAgent
(extends PageAgent:核心主循环)
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
ComputerMidsceneTools | 定义 computer_* 工具、按 init 参数造/复用 agent | packages/computer/src/agent-tools.ts:183 |
runToolsCLI | 把工具定义变成 CLI 子命令,剥掉前缀 | packages/shared/src/cli/cli-runner.ts:196 |
agentForComputer / agentForRDPComputer | 建设备并包成 agent | packages/computer/src/agent.ts:65 / :73 |
ComputerAgent | 薄壳,直接继承核心 PageAgent | packages/computer/src/agent.ts:22 |
| RDP 子系统 | 用 helper 二进制驱动远程 Windows | packages/computer/src/rdp/* |
主线走一遍(高层): 调用方发来 computer_connect → ComputerMidsceneTools 从参数里抽出 init 参数 → 看有没有 host 决定 local/rdp → 建对应设备、connect() → 包成 ComputerAgent → 回传第一帧截图。之后的 tap/type 等工具复用同一个 agent,把请求交给设备执行。
3. 核心原理(逐个机制,由浅入深)
3.1 一份工具定义,喂给 MCP 也喂给 CLI
要解决的小问题: 同一批能力,既要能被 AI(MCP)调,又要能被人(CLI)敲,不想写两遍。
思路: 工具就是数据——一个个 ToolDefinition(名字 + schema + handler)。MCP 直接用这份数据;CLI 只是在它上面套一层「把工具名当子命令、把参数当 --flag」的适配。
装配入口在哪: ComputerMidsceneTools 继承自通用基类,声明自己是「操作 ComputerAgent、吃 ComputerInitArgs」的一套工具:
// agent-tools.ts:173 —— 真实源码
export class ComputerMidsceneTools extends BaseMidsceneTools<
ComputerAgent,
ComputerInitArgs
> {
平台专属工具(connect/disconnect/list_displays)由 preparePlatformTools() 提供(agent-tools.ts:251);其余操作工具由基类 initTools() 从「动作空间」自动生成(base-tools.ts:265,原理见 04)。两者合成一份 ToolDefinition[]。
CLI 这一侧只是入口文件里薄薄一层:把这套工具交给通用 runToolsCLI,并告诉它剥掉 computer_ 前缀。
// cli.ts:6 —— 真实源码,整个 CLI 入口就这么点
const tools = new ComputerMidsceneTools();
runToolsCLI(tools, 'midscene-computer', {
stripPrefix: 'computer_',
version: __VERSION__,
extraCommands: createReportCliCommands(),
});
runToolsCLI 遍历工具定义,用 removePrefix(def.name, options?.stripPrefix) 把 computer_connect 变成子命令 connect(cli-runner.ts:233 调用 removePrefix,后者定义在 cli-runner.ts:93)。
关键点: MCP 与 CLI 不是两套实现,而是同一份 ToolDefinition[] 的两种呈现。CLI 侧甚至不认识「computer」——它是完全通用的 runner,只靠 stripPrefix 这一个参数把某平台的工具铺成子命令。
3.2 传不传 host,决定 local 还是 rdp
要解决的小问题: 调用方不想显式说「我要本地模式还是远程模式」;能不能看参数自己判断?
思路: 用一个「有 host 就是远程,没有就是本地」的规则。调用方只管填参数,模式(mode)由代码补齐。
init 参数的形状 computerInitArgShape 把两组参数放在一起,并用 .describe() 写清了这条规则(agent-tools.ts:37):
| 参数组 | 字段(节选) | 生效条件 |
|---|---|---|
| 本地 | displayId、headless | 没传 host 时;传了 host 会被忽略 |
| RDP | host、port、username、password、domain、securityProtocol、ignoreCertificate、adminSession、desktopWidth/Height | 传了 host 才生效 |
| 通用行为 | aiActContext、replanningCycleLimit、waitAfterAction… | 两种模式都吃(来自 agentBehaviorInitArgShape) |
判定就一行:adaptComputerInitArgs 看 extracted.host 是否存在,补上 mode 并丢掉不相关字段。
// agent-tools.ts:131 —— 真实源码(节选)
if (extracted.host) {
// 丢掉本地专属字段;RDP 模式下它们没意义
const { displayId: _d, headless: _h, ...rdpFields } = extracted;
const host = normalizeRdpHost(extracted.host);
return { mode: 'rdp', ...rdpFields, host };
}
return { mode: 'local', displayId: extracted.displayId, /* … */ };
这个函数被挂在 initArgSpec.adapt 上(agent-tools.ts:189),而 initArgSpec 是基类识别 CLI/MCP 参数的声明式配置(base-tools.ts:39 的 InitArgSpec)。也就是说:从命令行 flag 或 MCP 参数,到「local 还是 rdp」的判定,是同一条数据管线,调用方永远不用手写 mode(这正是 ComputerInitArgs 那个 discriminated union 的注释所说的,agent-tools.ts:112)。
一个小细节(容易忽略): host 会先过 normalizeRdpHost,把 IPv6 那种 [::1] 外层方括号剥掉(rdp/address.ts:3),保证后面拼地址时不会重复加括号。
3.3 ensureAgent:按 init 参数「签名」复用或重建 agent
要解决的小问题: 连续调用 tap、type、scroll,不该每次都新建一个 agent(重连桌面代价大);但如果参数变了(比如换了 host),又必须重建。怎么判断「参数变没变」?
思路: 把 init 参数序列化成一个稳定签名字符串,和上次的比。相同就复用,不同就销毁旧的、建新的。
// agent-tools.ts:198 —— 真实源码(节选)
protected async ensureAgent(opts?: ComputerInitArgs): Promise<ComputerAgent> {
const nextSignature = getAgentInitArgsSignature(opts);
if (this.agent &&
shouldRebuildAgentForInitArgs(this.lastInitArgsSignature, nextSignature)) {
await this.agent.destroy?.(); // 参数变了 → 拆掉旧 agent
this.agent = undefined;
}
if (this.agent) return this.agent; // 参数没变 → 直接复用
// …否则按 mode 走 rdp / local 分支新建
}
签名与「该不该重建」的判断是纯函数,住在 shared 里:
getAgentInitArgsSignature:把参数对象按 key 排序后JSON.stringify,保证同样内容永远得到同样字符串(agent-behavior-init-args.ts:91,内部用stableJsonValue递归排序)。shouldRebuildAgentForInitArgs:两个签名不等、且不是「都为空」时,才判定需要重建(agent-behavior-init-args.ts:101)。
local 与 rdp 两条建法(agent-tools.ts:219 起):mode === 'rdp' 时脱掉 mode 字段调 agentForRDPComputer;否则拼出 displayId/headless/行为参数调 agentFromComputer。两条路都把 CLI 报告选项(readCliReportAgentOptions)一并塞进去。
一个反直觉的地方: computer_connect 的 handler 不走上面这套「复用」逻辑——它先无条件销毁已有 agent、清空签名,再 ensureAgent(agent-tools.ts:269)。也就是说 connect 永远重连;而 tap 这类动作工具走的是 ensureAgent(extractAgentInitParam(args))(base-tools.ts:295),同参数会复用。语义上很合理:显式 connect 就是「我要一条新会话」。
3.4 connect 回传第一帧截图
要解决的小问题: 调用方 连上之后,第一件事总是想「看看现在屏幕长啥样」。
思路: 把「连接」和「拿首帧」合成一步,connect 的返回里直接带一张截图。
// agent-tools.ts:278 —— 真实源码(节选)
const agent = await this.ensureAgent(initArgs);
const screenshot = await agent.interface.screenshotBase64();
return {
content: [
{ type: 'text', text: `Connected to computer${describeConnectTarget(initArgs)}` },
...this.buildScreenshotContent(screenshot),
],
};
两个细节:
- 截图是从
agent.interface拿的——interface就是底层设备(ComputerDevice或RDPDevice),这是核心PageAgent暴露的属性(packages/core/src/agent/agent.ts:149)。同一行代码,两种后端都成立,因为两种设备都实现了screenshotBase64()