跳到主要内容

对外形态:computer_* 工具、本地/RDP 两种后端

30 秒导读: 前面几章讲的是 computer-use 的「内功」——主循环、视觉定位、模型、设备执行。这一章讲外功:这些能力怎么被打包成一个能用的产品面。核心就两件事:(1) 一套 computer_* 工具定义,同时被 MCP server 和命令行复用;(2) 调用方只要传不传 host,同一套工具背后就在本地桌面RDP 远程 Windows 两种后端之间切换。

本章聚焦装配与形态边界。真正的循环 / 定位 / 模型 / 设备执行,分别见 01-agent-loop02-vision-grounding03-model-family-adapters04-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 参数造/复用 agentpackages/computer/src/agent-tools.ts:173
runToolsCLI把工具定义变成 CLI 子命令,剥掉前缀packages/shared/src/cli/cli-runner.ts:175
agentForComputer / agentForRDPComputer建设备并包成 agentpackages/computer/src/agent.ts:60 / :73
ComputerAgent薄壳,直接继承核心 PageAgentpackages/computer/src/agent.ts:19
RDP 子系统用 helper 二进制驱动远程 Windowspackages/computer/src/rdp/*

主线走一遍(高层): 调用方发来 computer_connectComputerMidsceneTools 从参数里抽出 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:264,原理见 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, 'computer_')computer_connect 变成子命令 connect(cli-runner.ts:207 调用 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):

参数组字段(节选)生效条件
本地displayIdheadless没传 host 时;传了 host 会被忽略
RDPhostportusernamepassworddomainsecurityProtocolignoreCertificateadminSessiondesktopWidth/Height传了 host 才生效
通用行为aiActContextreplanningCycleLimitwaitAfterAction两种模式都吃(来自 agentBehaviorInitArgShape)

判定就一行:adaptComputerInitArgsextracted.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:38InitArgSpec)。也就是说:从命令行 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

要解决的小问题: 连续调用 taptypescroll,不该每次都新建一个 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:294),同参数会复用。语义上很合理:显式 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 就是底层设备(ComputerDeviceRDPDevice),这是核心 PageAgent 暴露的属性(packages/core/src/agent/agent.ts:124)。同一行代码,两种后端都成立,因为两种设备都实现了 screenshotBase64()
  • 返回文案里的 describeConnectTarget 会按模式给出人话:RDP 显示 via RDP (host:port as user),本地显示 (Display: …)(Primary display)(agent-tools.ts:149)。

4. Agent 装配:两个工厂,一个薄壳

这一节回答「ensureAgent 里那两个工厂函数到底做了什么」。

核心事实:ComputerAgent 几乎是空的。 它只是继承核心 PageAgent,换了个名字:

// agent.ts:19 —— 真实源码,类体是空的
export class ComputerAgent<
InterfaceType extends AbstractInterface = ComputerInterface,
> extends PageAgent<InterfaceType> {}

所有「计划—执行—重规划」的主循环都在 PageAgent 里(见 01)。computer 包不重写循环,只负责换设备

两个工厂只差在建哪种设备:

工厂建的设备定义
agentForComputer(别名 agentFromComputer)ComputerDevice(本机)agent.ts:60,别名 agent.ts:71
agentForRDPComputerRDPDevice(远程)agent.ts:73

两者结构一模一样——建设备、connect()、包 agent:

// agent.ts:73 —— 真实源码,RDP 版
export async function agentForRDPComputer(
opts: RDPComputerAgentOpt,
): Promise<ComputerAgent<RDPDevice>> {
const device = createRDPComputerDevice(opts);
await device.connect();
return new ComputerAgent(device, opts);
}

createLocalComputerDevice / createRDPComputerDevice(agent.ts:23 / :35)只是把 opts 里的字段挑出来传给对应设备构造函数,把连接配置与运行时对象分开。

一点提醒: agentFromComputeragentForComputer@deprecated 别名(agent.ts:69),但 ensureAgent 的 local 分支目前仍在用它(agent-tools.ts:240)——功能等价,只是命名过渡期。


5. 远程后端概览:RDP 子系统(只讲结构与职责)

本节只讲「远程模式那条路由由哪些文件、各管什么」;逐行执行细节属于设备层,见 04

怎么读这条链: 从上层「语义动作」一路降到「一个跨进程的 JSON 协议」,最后由一个平台原生二进制真正说 RDP 协议。

RDPDevice.inputPrimitives tap / type / scroll 等语义动作
│ (rdp/device.ts:64,含平滑移动、清空输入等)

RDPBackendClient (接口) mouseMove/mouseButton/keyPress/screenshot…
│ (rdp/protocol.ts:110 定义接口)

HelperProcessRDPBackendClient spawn 子进程;每条请求一行 JSON,走 stdin/stdout
│ (rdp/backend-client.ts:114)

rdp-helper 二进制 按平台(darwin/linux/win32.exe)定位可执行文件
│ (rdp/helper-binary.ts:38)

远程 Windows 桌面 真正的 RDP 连接

各文件职责一览:

文件职责
rdp/protocol.ts纯类型:连接配置 RDPConnectionConfig、请求/响应消息、RDPBackendClient 接口。是这条链的「合同」。
rdp/address.ts主机名/地址的规整与格式化:剥 IPv6 方括号、拼 host:port(normalizeRdpHostformatRdpServerAddress)。
rdp/device.tsRDPDevice implements AbstractInterface:把 Midscene 的输入原语(点击/输入/滚动)翻译成 backend 的 mouse*/key*/wheel 调用,并做平滑移动、聚焦、清空输入等交互细节。
rdp/backend-client.ts传输层:HelperProcessRDPBackendClient 拉起 helper 子进程,用「一行一条 JSON」的请求/响应协议通信,管理进程生命周期、超时、错误诊断;另有一个 UnsupportedRDPBackendClient 桩,所有方法都抛「未实现」。
rdp/helper-binary.tsprocess.platformbin/<platform>/ 下定位 rdp-helper(Windows 是 .exe),找不到就抛「先跑 build:native」。

几个值得记住的设计选择:

  • 协议即合同,类型先行。 rdp/protocol.ts 把「能发哪些请求、会收哪些响应」写成 RDPProtocolRequest / RDPProtocolResponse 联合类型(protocol.ts:33 / :76)。device 和 backend 都只依赖这份类型,互不知道对方实现。

  • 只传可序列化的连接配置。 RDPDevice.connect() 在把配置发给 helper 前,显式剔除了 backendcustomActions 这些运行时对象——注释点明:backend 里握着一个带循环引用的活子进程,漏进 JSON 会「污染请求行」(rdp/device.ts:224)。

  • 默认后端就是 helper 进程版。 createDefaultRDPBackendClient() 返回 HelperProcessRDPBackendClient(backend-client.ts:606);那个 UnsupportedRDPBackendClient 是给「没有真实传输时」的占位/测试注入用的(backend-client.ts:54)。


6. 巧妙之处(可借鉴的技术)

  • 「工具即数据」让一份定义长出两个面。 平台只产出 ToolDefinition[],MCP 直接用、CLI 靠 stripPrefix 铺成子命令(cli-runner.ts:207)。加一个平台无需改 CLI runner。

  • host 的有无做隐式模式判定。 调用方零心智负担,adaptmode、丢无关字段(agent-tools.ts:125);discriminated union 让后续分支类型安全。

  • 稳定签名做 agent 复用。 key 排序后 JSON.stringify,把「参数变了没」这种模糊问题变成字符串相等判断(agent-behavior-init-args.ts:91),既省重连又保证换配置时一定重建。

  • 本地/远程共用核心。 ComputerAgent 是空壳,循环全在 PageAgent;换后端只换 interface 那一格,connect 回传截图这类代码两种后端一字不改。


7. 边界与局限

  • RDP 真正的像素/协议由外部二进制承担。 TS 侧只做进程管理与 JSON 协议;rdp-helper 不存在时 getRdpHelperBinaryPath 直接抛错(helper-binary.ts:64),需先 build:native。平台不在 darwin/linux/win32 之列同样抛错。

  • RDP 目标面向远程 Windows 桌面。 配置项(adminSessionsecurityProtocol 等)都是 Windows RDP 语义;本地专属的 displayId/headless 在 RDP 模式被显式丢弃(agent-tools.ts:133)。

  • list_displays 两种模式含义不同。 本地枚举真实显示器(ComputerDevice.listDisplays,device.ts:891);RDP 模式只返回单个「远程会话」条目(rdp/device.ts:257ListDisplays 动作)。

  • 本章不覆盖执行内幕。 输入原语如何落到真实像素、动作空间如何生成工具、模型如何被调用——分别见 04030201


8. 代码地图(导航索引)

主题文件符号
computer 工具集/装配packages/computer/src/agent-tools.tsComputerMidsceneTools
computer_* 平台工具packages/computer/src/agent-tools.tspreparePlatformTools
init 参数形状packages/computer/src/agent-tools.tscomputerInitArgShape
local/rdp 模式判定packages/computer/src/agent-tools.tsadaptComputerInitArgs
agent 复用/重建packages/computer/src/agent-tools.tsensureAgent
连接目标描述packages/computer/src/agent-tools.tsdescribeConnectTarget
init 参数声明式规范packages/shared/src/agent-tools/base-tools.tsInitArgSpecBaseMidsceneTools
参数签名/是否重建packages/shared/src/agent-tools/agent-behavior-init-args.tsgetAgentInitArgsSignatureshouldRebuildAgentForInitArgs
本地/RDP 工厂packages/computer/src/agent.tsagentForComputeragentForRDPComputer
agent 薄壳packages/computer/src/agent.tsComputerAgent
CLI 入口packages/computer/src/cli.ts(顶层脚本)
CLI runner / 剥前缀packages/shared/src/cli/cli-runner.tsrunToolsCLIremovePrefix
RDP 设备packages/computer/src/rdp/device.tsRDPDevice
RDP 传输后端packages/computer/src/rdp/backend-client.tsHelperProcessRDPBackendClientcreateDefaultRDPBackendClient
RDP 协议/类型packages/computer/src/rdp/protocol.tsRDPConnectionConfigRDPBackendClient
RDP 地址规整packages/computer/src/rdp/address.tsnormalizeRdpHostformatRdpServerAddress
helper 二进制定位packages/computer/src/rdp/helper-binary.tsgetRdpHelperBinaryPath