feat: replace marketplace OCU with Cua Driver 0.36.0

This commit is contained in:
chenmingxuan committed 2026-10-17 13:01:39 +08:00
1 parent fd92bafcb5
commit c89588a80d
19 files changed
+2563 -40

No files matched your search

+20 -23
View File
@@ -1,38 +1,35 @@
# Artemis Plugins
ARTEMIS 2.0.0 的插件市场仓库,用于分发经过验证的插件,供用户在 ARTEMIS 插件页面安装。
ARTEMIS 2.0.0 的插件市场。当前电脑控制插件为 **Cua 0.36.0**,支持 Windows x64,通过窗口截图与可访问性树操作电脑应用和浏览器窗口,复用 ARTEMIS 的调度、资源互斥、步骤、截图附件和录像。
电脑控制 1.0.0 以开发预览提供市场安装,适用于 Windows x64 和 ARTEMIS 2.0.0 开发版。已验证基础窗口、浏览器表单和 Flash 手机联动;复杂控制台、多 DPI、多显示器和长程场景仍需验证。具体结果见 [验证记录](plugins/open-computer-use/VERIFICATION.md)。
在 ARTEMIS 的「插件」页面安装「电脑控制 · Cua」,授权并启用即可。安卓任务可根据提示词按需调用电脑控制,不需要单独配置 Codex MCP 或安装 Go、Node、Python。
## 仓库内容
## 从 OCU 切换
- `catalog.json`:市场索引,当前契约版本为 1,空列表表示没有已发布插件。
- `plugins/open-computer-use`:首发插件清单、适配 Skill 与验证记录。
- `scripts/build_computer_use.py`:校验固定的 npm 1.1.0 安装包与上游源码,在构建阶段应用 Windows 截图和输入适配,运行 Go 测试并编译,生成包含源码、许可证的 ZIP 与 SHA-256;不执行 npm 安装脚本。
- 安装包通过本仓库的发行附件分发;`artifacts/` 下的本地测试包不进入 Git。
Open Computer Use(OCU)已从市场索引下架,当前市场只提供 Cua。已安装 OCU 的用户请先安装并启用 Cua,再停用 OCU,避免同一任务加载两套电脑控制工具。排队、暂停或运行中的任务结束后再切换。
首发计划仅适配 [open-codex-computer-use](https://github.com/iFurySt/open-codex-computer-use),通过同一插件操作 Windows 应用及浏览器窗口。用户在插件页面安装、授权并启用后,安卓任务可由 AI 按需调用,无需选择组合执行目标。
旧插件的源码、发行附件、历史任务与证据继续保留。下架不会删除用户的已安装版本,也不会替其他用户自动授权新插件。需要复核旧任务时仍可按其固定版本处理。
## 本地构建和验证
## 验证范围
构建机需要 Python 和 Go 1.22+;最终用户安装包不需要 Go。准备 npm `open-computer-use@1.1.0` 原始 tarball,以及上游提交 `7d8cb3fa5618d1ed8ec71773434ac3677f1922c9` 的干净 Git 检出:
本版本沿用 Cua 0.1.2 候选的驱动与动作实现,固定上游 [Cua Driver 0.36.0](https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.36.0) 预发布版,继续标注开发预览。
已测试原生窗口表单、34 像素视觉目标、滚动、截图保存、ARTEMIS 窗口录像和子进程退出。对照测试独立验收 3/3 完成;ARTEMIS AI 裁定为 2 次通过、1 次未判定。浏览器复杂控制台、多 DPI、多显示器与安卓混合长程任务仍需进一步验证,详见 [验证记录](plugins/cua-driver/VERIFICATION.md)。
## 构建与验证
构建机需要 Python 和 Go;最终用户只安装 ZIP。从固定上游发行下载 `cua-driver-rs-0.36.0-windows-x86_64-binary.zip`,构建脚本校验 SHA256 后打包,不执行全局安装器。
```sh
python scripts/build_computer_use.py open-computer-use-1.1.0.tgz --upstream-source <上游源码目录> --go <go可执行文件>
python tests/windows_capture_smoke.py artifacts/open-computer-use.exe
python tests/browser_smoke.py artifacts/open-computer-use.exe --chrome <Chrome可执行文件>
python scripts/build_cua_driver.py <上游ZIP> --go <Go可执行文件>
python tests/cua_smoke.py <安装后的artemis-cua-adapter.exe> artifacts/cua-smoke
python tests/cua_fixture_validation.py <安装后的artemis-cua-adapter.exe>
```
第二条命令使用安装了 ARTEMIS 依赖的 Python,创建并关闭两个临时窗口,验证遮挡截图、坐标对应和按钮实际收到点击。不会操作用户已有应用。
验证脚本使用已安装 ARTEMIS 依赖的 Python,并将 ARTEMIS 源码目录放入 `PYTHONPATH`。它们只操作自行创建的测试窗口。历史 A/B 脚本仍固定 OCU 1.1.0 与 Cua 0.1.2,便于复现已有测量;它不是安装后必须执行的步骤。
第三条命令创建独立 Chrome 配置和本地测试表单,通过 MCP 填写中文、提交一次并读回结果,退出后关闭测试浏览器。
`catalog.json` 是市场索引,包含清单、HTTPS 安装包地址、SHA256 和验证说明。`artifacts/` 保存本机安装包与测试资料,不进入 Git。版本文件不可原地覆盖,任务固定使用其启动时的版本。
ARTEMIS 适配将屏幕区域截取改为目标窗口 `PrintWindow`;失败或空白画面不回退为截屏。鼠标消息根据目标窗口的子控件路由,工具声明不再向模型推荐 Windows 未实现的点击方式。适配源文件与构建后的完整源码都随包分发,未修改上游仓库。
插件版本跟随所用 Cua Driver 上游版本:当前为 `0.36.0`,对应上游标签 `cua-driver-rs-v0.36.0`,本仓库发行标签为 `cua-driver-v0.36.0`。只修改 ARTEMIS 适配而未升级上游时使用 `+artemis.N` 后缀区分不可变安装包;包内 `provenance.json` 记录上游提交、原包校验值和适配修订,构建会检查版本对应关系。
## 发布约定
每个可安装版本需要声明支持的 ARTEMIS 版本和平台,固定运行依赖,提供安装包校验值、更新说明及实际验证记录。插件配置不包含账号密钥、个人目录或设备信息。
安装使用最新已验证且兼容的版本;后续升级由用户在 ARTEMIS 中操作,正在执行的任务固定插件版本。
[ARTEMIS 主项目](https://git.playones.com/chenmingxuan/Artemis-Eapil)
[发行说明](plugins/cua-driver/RELEASE.md) · [ARTEMIS 主项目](https://git.playones.com/chenmingxuan/Artemis-Eapil)
+147 -16
View File
@@ -3,11 +3,11 @@
"plugins": [
{
"manifest": {
"schema_version": 1,
"id": "open-computer-use",
"name": "电脑控制",
"version": "1.0.0",
"description": "通过 Windows 窗口截图与可访问性树观察并操作桌面应用、浏览器控制台;可与手机任务组合。",
"schema_version": 2,
"id": "cua-driver",
"name": "电脑控制 · Cua",
"version": "0.36.0",
"description": "通过 Cua Driver 的窗口截图与可访问性树操作 Windows 应用及浏览器窗口;复用 ARTEMIS 的任务调度、步骤证据与录像。",
"artemis_version": ">=2.0.0,<3.0.0",
"platforms": [
"win32"
@@ -15,14 +15,11 @@
"architectures": [
"amd64"
],
"source": "https://github.com/iFurySt/open-codex-computer-use",
"source": "https://github.com/trycua/cua",
"license": "MIT",
"server": {
"transport": "stdio",
"command": "${plugin_dir}/bin/open-computer-use.exe",
"args": [
"mcp"
]
"command": "${plugin_dir}/bin/artemis-cua-adapter.exe"
},
"skill": "SKILL.md",
"targets": [
@@ -33,17 +30,151 @@
"desktop:local"
],
"observation": {
"tool": "list_apps",
"arguments": {},
"tool": "list_windows",
"arguments": {
"on_screen_only": true
},
"refresh_tools": [
"get_app_state"
"get_window_state"
]
},
"capabilities": {
"kind": "visual",
"recording": "windows",
"tools": {
"list_windows": {
"effect": "read",
"observation": true,
"idempotent": true,
"target_argument": "",
"cancellation": "unknown",
"evidence": [
"text"
]
},
"get_window_state": {
"effect": "read",
"observation": true,
"idempotent": true,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"verify_state": {
"effect": "read",
"observation": false,
"idempotent": true,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"click": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"drag": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"type_text": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"press_key": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"set_value": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"scroll": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"save_screenshot": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image",
"file"
]
},
"click_at": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
}
},
"required_tools": [
"list_windows",
"get_window_state",
"save_screenshot"
]
}
},
"release_stage": "preview",
"archive_url": "https://git.playones.com/attachments/3273b838-4d64-442d-84c2-1a590ce8a81d",
"sha256": "b4fcc9ca6a1f0137bba6f4db93d524fa57cfa02a0d53178ac28e7521a97785bf",
"verification": "Windows x64 / ARTEMIS 2.0.0 开发版;2026-10-14。\n已验证:窗口观察、中文输入、子控件点击、遮挡截图;Chrome 基础表单填写、提交与重新读取;Flash/Pro 纯电脑基础任务;Flash 电脑修改参数、手机验证、恢复参数和再次验证,步骤截图及 Word/Excel 导出。\n未覆盖:混合 Pro、复杂控制台、多 DPI、多显示器、长程压力和真实批次组合。部分应用不支持窗口截图,最小化窗口不可用于坐标操作。完整记录见插件仓库 VERIFICATION.md。"
"archive_url": "https://git.playones.com/chenmingxuan/Artemis-Plugins/releases/download/cua-driver-v0.36.0/cua-driver-0.36.0-windows-amd64.zip",
"sha256": "dea40dce182ac6b982c7da17d3610d5db87e2639205ca32f179c55ca1d7833ce",
"verification": "Windows x64 / ARTEMIS 2.0.0 开发版。固定 Cua Driver 0.36.0 预发布版。已验证原生窗口表单、控件与像素点击、34 像素目标、滚动、trace 截图及哈希、ARTEMIS PC 窗口录像、子进程退出;同一复合用例重复 3 次,独立验收 3/3,AI 裁定 2 次通过、1 次未判定。0.36.0 沿用已测 0.1.2 动作实现,取代 OCU 市场入口。未覆盖复杂浏览器控制台、多 DPI、多显示器和跨端长任务。完整验证与已知限制见仓库 plugins/cua-driver/VERIFICATION.md。"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Cua AI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
# Cua 电脑控制 0.36.0
作为 ARTEMIS 插件市场的默认电脑控制实现,替代已下架的 Open Computer Use。此版本沿用候选 0.1.2 的驱动和动作逻辑,更新版本标识、名称和分发文档;上游仍是固定的 Cua Driver 0.36.0 预发布版,市场标注开发预览。
上游提交:`6d6c734c127025fb39ad5d585a10382808b39dc1`。Windows x64 原始 ZIP SHA256:`f8c47024b626f4e0e70f8428d2998df39e4879110c5722a29d5fd03e367353ad`。采用 MIT Driver,许可证随包提供;不包含 Cua Spaces、云环境或模型服务。
提供 11 个窗口观察、控件/像素点击、输入、按键、拖动、滚动及截图工具。操作后返回真实窗口观察,保留动作回执,截图校验 PID/HWND 与物理边界,生成文件保存到当前任务 trace。共享 ARTEMIS 的 `desktop:local` 资源、任务固定版本、步骤和录像,不另建调度器。
适配器与依赖随包分发,无需额外安装 Go、Python、Node 或另一份 Codex MCP。关闭、取消或宿主退出后通过 Windows Job Object 回收 Driver/UIA 子进程;仅在该子进程环境关闭遥测。
升级:任务结束后,在插件页面安装并授权启用 Cua,再停用 OCU。下架不删除旧包、历史报告或用户配置,不自动替其他用户授权。完整实测结果与尚未验证的范围见 [VERIFICATION.md](VERIFICATION.md)。
+13
View File
@@ -0,0 +1,13 @@
# Cua 电脑控制(Windows)
通过本地 Cua Driver 操作用户授权的 Windows 窗口。调度、资源互斥、模型、步骤、截图和录像由 ARTEMIS 提供;插件本身不运行 AI,不另起后台服务。
1. `list_windows` 找到目标的 `pid` 和 `window_id`;只操作任务要求的窗口,不关闭其他应用。
2. `get_window_state` 同时读取截图和 UIA 控件树。最新 `snapshot_id` 加 `:N` 即控件 `element_token`。优先用 `click` 点击最新控件;视觉目标用 `click_at`,坐标仅取本次截图上的窗口局部像素,不能用屏幕坐标,需携带本次 `capture_id`。两种点击参数互斥,不混用。
3. 所有窗口操作明确传 `pid`、`window_id`。`set_value` 适合直接设置输入框;不支持时可明确聚焦后 `type_text`。观察控件支持的动作,不猜元素编号。`press_key` 传单键及 `modifiers` 数组。
4. 每个动作后插件自动返回一次新截图和控件树,新 snapshot 替换旧 token。接着用新 token,无需重复获取相同状态。仅当画面确实还在变化或需要核验时再次观察。一次调用只执行一次动作,超时不自动重发。
5. 默认后台操作。`effect=unverifiable/suspected_noop` 仅代表投递/效果未确认,必须检查返回画面和读回值;`refused` 是未执行。后台无效时,可在任务授权范围内显式 `delivery_mode=foreground` 后再次检查。不要仅凭工具成功声称测试通过。
6. `scroll` 需要最新窗口/滚动区域,使用 up/down/left/right、amount、by=line/page;先观察是否真的滚动。`get_window_state(since="latest")` 可在同一窗口读取差异,但新 snapshot_id 仍替换旧 token。
7. `save_screenshot` 保存最新窗口 PNG 到当前任务 trace,filename 仅是文件名,不能传路径,不覆盖已有文件。实际成功返回 SHA256 和附件;不得把工具内存截图当成已保存文件。
不提供 Cua 自带调度、录屏、回放、配置修改、安装器、任意脚本或自动升级接口。浏览器本轮使用窗口视觉/UIA 通路;未接入 DOM 或已有登录浏览器调试权限。此包基于上游 0.36.0 预发布版,通过已列出的本机验证,其他场景的支持范围以 VERIFICATION.md 为准。
+44
View File
@@ -0,0 +1,44 @@
# Cua 验证记录
## 候选阶段记录
以下记录对应市场替换前的候选测试。适配版本 0.1.2;上游 Cua Driver 0.36.0(预发布),仅 Windows x64。默认模型和默认插件保持原样。
端到端联调还发现 ARTEMIS SDK 配置构造遗漏 `recording`,导致任务指定窗口录制却使用默认屏幕录制。主项目已补传该配置,覆盖从任务默认值、SDK Builder 到 PluginRecordings 的回归;新工作进程实际录像索引已确认 `scope=window`。早期屏幕录像只保留在本机,测量报告披露各次实际录制范围,不混同为统一录制条件。
已验证:
- 11 个工具符合 ARTEMIS schema 2 清单,命名、只读声明与窗口参数匹配。
- HWND/PID 与物理窗口边界核对、真实窗口截图、动作后新 snapshot/token。
- 独立窗口确认点击实际到达;表单值与勾选状态正确,蓝色小目标命中,滚动到末尾后完成。
- 后台滚动的明确预检拒绝与前台执行区分;未自动重试写操作。
- 新 PNG 保存到 trace,读回 SHA256 校验,附件被证据索引接收;拒绝覆盖已有文件与路径逃逸。
- 使用 ARTEMIS 原有录像管理器录制 PC 窗口,H.264 文件确实生成;关闭会话后候选适配器、Driver 和 UIA 子进程均退出。
- Go 回归覆盖动作回执、未知结果、拒绝、参数边界与两种点击路径互斥;Python 工具经过 Ruff 检查。
联调发现并修正:0.1.0 同一点击工具允许控件与像素参数,模型混填导致上游拒绝;0.1.1 拆分 `click` 与 `click_at`。0.1.2 补充上游 Windows 类级 `background_unavailable` 的未执行回执;带 actuator cause 的失败仍保留不确定状态。
实际 A/B 数据由 `tests/benchmark_plugins.py` 和 `tests/summarize_benchmark.py` 生成,保存于本机 `artifacts/`。测试使用独立窗口、固定任务和统一调度,比较表单、34 像素视觉目标、滚动及截图留档;不能由这些小样本外推浏览器、所有 DPI、真实路由器或长程任务的成功率。模型无工具调用、兜底模型与网关缓存情况必须随数据披露。
2026-10-17 完成一个复合用例各 3 次:Flash,主模型 `mimo-v2.6-flash/high`,保留 `gpt-6-luna` 兜底,480 秒任务时限。对照为当前 OCU 1.1.0 适配包。独立窗口事件判定结果如下(token 包含执行、摘要、压缩与最终核验):
| 指标 | OCU 1.1.0 | Cua 0.1.2 |
| --- | --- | --- |
| 全部动作与截图独立验收 | 0/3 | 3/3 |
| 蓝色目标恰好命中一次、没有误点 | 3/3 | 3/3 |
| 平均总 token | 683,748 | 504,954 |
| 缓存输入 / 总输入 | 51.0% | 49.7% |
| 平均任务结束/停止耗时 | 478.8 秒(超时) | 303.2 秒 |
| 平均插件调用耗时合计 | 32.2 秒 | 5.2 秒 |
OCU 在滚动回退中漏传 `WindowBounds`,导致反复报坐标不可用;此为对照版本的具体缺陷,本次未中途修改。Cua 第一轮有一次旧 token 拒绝,刷新后成功。两者的小目标点击均成功,不能据此宣称 Cua 普遍点击更准。Cua 的 token 少 26.1% 是本轮观察值,包含旧插件失败重试开销,不是所有成功任务的固定节省。
Cua 第三轮虽然独立验收通过,ARTEMIS 最终核验却没有生成结论,保留 `inconclusive`;AI 裁定实际为 2 次通过、1 次未判定。三次截图均可解码、SHA256 与保存回执一致;最后一轮窗口录像全片解码通过,约 173 秒、5.8 MiB。测试结束后队列空闲,候选适配器、Driver、UIA helper 和测试窗口进程均已退出。
主项目 SDK 配置修复通过默认回归 2761 项(2 项跳过、8 项未选中),Ruff、质量基线和核心类型检查通过。候选继续仅在非默认测试配置启用;没有修改市场索引。录像范围的中途修复、模型兜底以及最终核验延迟均在本地报告中保留,不将本次开发测试包装为严格受控的性能实验。
未验证:真实业务跨端长程任务、Chrome/Brave 特定页面、多个 DPI/显示器、非 Windows 平台、DOM 路由。这些未测范围仍不作稳定性保证;市场替换决定见下一节。
## 市场替换版本 0.36.0
0.36.0 沿用 0.1.2 已测驱动和动作实现,仅更新发行版本、名称及文档,作为市场默认电脑控制插件取代 OCU。上游仍固定 0.36.0 预发布版,市场继续标注开发预览;上文的未测范围和 AI 裁定缺失情况仍然有效。旧 OCU 从 catalog.json 下架,既有源码、发行附件和任务证据保留。已安装 OCU 的用户应安装并启用 Cua,再停用 OCU;不会静默为其他用户授权新代码。
+3
View File
@@ -0,0 +1,3 @@
module artemis-cua-adapter
go 1.22
+448
View File
@@ -0,0 +1,448 @@
// ARTEMIS adapter for the pinned MIT Cua Driver. No model, scheduler, or recorder.
package main
import (
"bufio"
"bytes"
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
//go:embed tools.json
var toolsJSON []byte
type obj = map[string]any
type request struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params obj `json:"params"`
}
type peer struct {
cmd *exec.Cmd
input io.WriteCloser
lines chan []byte
next int
mu sync.Mutex
dead bool
job uintptr
}
func object(v any) obj {
m, _ := v.(map[string]any)
if m == nil {
return obj{}
}
return m
}
func str(v any) string { s, _ := v.(string); return s }
func num(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case int64:
return n
case int:
return int64(n)
}
return 0
}
func items(v any) []any { a, _ := v.([]any); return a }
func textBlock(s string) obj { return obj{"type": "text", "text": s} }
func receipt(execution, side, code string) obj {
return obj{"version": 1, "execution": execution, "side_effect": side, "retryable": false, "error_code": code}
}
func failure(msg, execution, side, code string) obj {
return obj{"isError": true, "content": []any{textBlock(msg)}, "structuredContent": obj{"artemis": receipt(execution, side, code)}}
}
func writeTool(name string) bool {
return name != "list_windows" && name != "get_window_state" && name != "verify_state"
}
func startPeer(exe string) (*peer, error) {
c := exec.Command(exe, "mcp", "--direct")
c.Env = append(os.Environ(), "DO_NOT_TRACK=1", "CUA_TELEMETRY=0", "CUA_DRIVER_RS_TELEMETRY_ENABLED=false")
configureProcess(c)
in, err := c.StdinPipe()
if err != nil {
return nil, err
}
out, err := c.StdoutPipe()
if err != nil {
return nil, err
}
c.Stderr = os.Stderr
if err = c.Start(); err != nil {
return nil, err
}
job, err := ownProcess(c.Process.Pid)
if err != nil {
_ = c.Process.Kill()
_ = c.Wait()
return nil, err
}
p := &peer{cmd: c, input: in, lines: make(chan []byte, 32), job: job}
go func() {
defer close(p.lines)
s := bufio.NewScanner(out)
s.Buffer(make([]byte, 65536), 32<<20)
for s.Scan() {
p.lines <- append([]byte(nil), s.Bytes()...)
}
}()
_, err = p.call("initialize", obj{"protocolVersion": "2024-11-05", "clientInfo": obj{"name": "artemis-cua-adapter", "version": "0.36.0"}, "capabilities": obj{}})
if err != nil {
p.stop()
return nil, err
}
err = json.NewEncoder(in).Encode(obj{"jsonrpc": "2.0", "method": "notifications/initialized"})
if err != nil {
p.stop()
return nil, err
}
return p, nil
}
func (p *peer) stop() {
p.mu.Lock()
defer p.mu.Unlock()
if p.dead {
return
}
p.dead = true
_ = p.input.Close()
closeJob(p.job)
_ = p.cmd.Process.Kill()
_ = p.cmd.Wait()
}
func (p *peer) call(method string, params obj) (obj, error) {
p.mu.Lock()
dead := p.dead
p.mu.Unlock()
if dead {
return nil, errors.New("driver closed")
}
p.next++
id := p.next
if err := json.NewEncoder(p.input).Encode(obj{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil {
return nil, err
}
timer := time.NewTimer(45 * time.Second)
defer timer.Stop()
for {
select {
case raw, ok := <-p.lines:
if !ok {
return nil, errors.New("driver disconnected")
}
var r obj
if json.Unmarshal(raw, &r) != nil {
continue
}
if num(r["id"]) != int64(id) {
continue
}
if r["error"] != nil {
return nil, errors.New("driver protocol error")
}
return object(r["result"]), nil
case <-timer.C:
p.stop()
return nil, errors.New("driver response timed out; action not replayed")
}
}
}
func (p *peer) tool(name string, args obj) (obj, error) {
return p.call("tools/call", obj{"name": name, "arguments": args})
}
type adapter struct {
peer *peer
tools []any
schemas map[string]obj
}
func newAdapter(p *peer) *adapter {
a := &adapter{peer: p, schemas: map[string]obj{}}
_ = json.Unmarshal(toolsJSON, &a.tools)
for _, v := range a.tools {
t := object(v)
a.schemas[str(t["name"])] = object(t["inputSchema"])
}
return a
}
func (a *adapter) validate(name string, args obj) error {
schema, ok := a.schemas[name]
if !ok {
return errors.New("tool is not exposed")
}
props := object(schema["properties"])
for key := range args {
if _, ok := props[key]; !ok {
return fmt.Errorf("unsupported argument: %s", key)
}
}
for _, key := range items(schema["required"]) {
if _, ok := args[str(key)]; !ok {
return fmt.Errorf("missing argument: %s", str(key))
}
}
if name != "list_windows" && (num(args["pid"]) <= 0 || num(args["window_id"]) <= 0) {
return errors.New("a concrete pid and window_id are required")
}
if name == "save_screenshot" && !validFilename(str(args["filename"])) {
return errors.New("filename must be a new plain PNG filename")
}
if name == "get_window_state" && (args["include_screenshot"] == false || args["include_accessibility_tree"] == false) {
return errors.New("candidate observations require both screenshot and accessibility tree")
}
return nil
}
func validFilename(s string) bool {
return s != "" && len(s) <= 160 && strings.EqualFold(filepath.Ext(s), ".png") && !strings.ContainsAny(s, "/\\:<>\"|?*\x00") && s != "." && s != ".." && !strings.HasSuffix(s, ".") && !strings.HasSuffix(s, " ")
}
func actionReceipt(r obj, write bool) obj {
if !write {
if r["isError"] == true {
return receipt("not_started", "none", "observation_failed")
}
return receipt("completed", "none", "")
}
s := object(r["structuredContent"])
// v0.36.0 class-level preflight refusal has no effect field. It occurs
// before input injection. Do not classify actuator failures with a cause
// as safe-to-repeat; retain their uncertain execution below.
if s["code"] == "background_unavailable" && s["cause"] == nil && s["event_kind"] != nil && r["isError"] == true {
return receipt("not_started", "none", "background_unavailable")
}
switch str(s["effect"]) {
case "confirmed":
return receipt("completed", "confirmed", "")
case "partial":
return receipt("partial", "confirmed", "partial_delivery")
case "refused":
return receipt("not_started", "none", "driver_refused")
case "unverifiable", "suspected_noop":
return receipt("completed", "possible", str(s["effect"]))
default:
return receipt("unknown", "possible", "unclassified_result")
}
}
func imageSize(r obj) (int, int) {
for _, v := range items(r["content"]) {
c := object(v)
if c["type"] == "image" {
raw, err := base64.StdEncoding.DecodeString(str(c["data"]))
if err == nil {
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err == nil {
return cfg.Width, cfg.Height
}
}
}
}
return 0, 0
}
func source(r obj, args obj, started float64) obj {
s := object(r["structuredContent"])
w, h := imageSize(r)
if r["isError"] == true || w <= 0 || h <= 0 || num(s["pid"]) != num(args["pid"]) || num(s["window_id"]) != num(args["window_id"]) {
return nil
}
bounds := object(s["window_bounds"])
rect := obj{"left": num(bounds["x"]), "top": num(bounds["y"]), "width": num(bounds["width"]), "height": num(bounds["height"])}
screen, ok := verifyWindow(num(args["pid"]), num(args["window_id"]), rect)
if !ok {
return nil
}
return obj{"kind": "computer", "target_id": fmt.Sprint(num(args["window_id"])), "label": s["window_title"], "captured_at": started, "width": w, "height": h, "window": rect, "screen": screen}
}
func (a *adapter) observe(args obj) (obj, obj, error) {
started := float64(time.Now().UnixMilli()) / 1000
r, err := a.peer.tool("get_window_state", args)
if err != nil {
return nil, nil, err
}
return r, source(r, args, started), nil
}
func observationArgs(args obj) obj {
return obj{"pid": args["pid"], "window_id": args["window_id"], "max_image_dimension": 1280, "max_elements": 250, "tree_format": "markdown"}
}
func (a *adapter) handle(name string, args obj) obj {
if err := a.validate(name, args); err != nil {
return failure(err.Error(), "not_started", "none", "invalid_input")
}
if name != "list_windows" {
if !windowOwned(num(args["pid"]), num(args["window_id"])) {
return failure("window is gone or belongs to a different process", "not_started", "none", "invalid_target")
}
}
if name == "save_screenshot" {
return a.save(args)
}
var r, src obj
var err error
if name == "get_window_state" {
for k, v := range observationArgs(args) {
if _, ok := args[k]; !ok {
args[k] = v
}
}
r, src, err = a.observe(args)
} else {
upstream := name
if name == "click_at" {
upstream = "click"
}
r, err = a.peer.tool(upstream, args)
}
if err != nil {
a.peer.stop()
if writeTool(name) {
return failure(err.Error(), "unknown", "possible", "transport_lost")
}
return failure(err.Error(), "not_started", "none", "transport_lost")
}
rec := actionReceipt(r, writeTool(name))
structured := object(r["structuredContent"])
if writeTool(name) && rec["execution"] != "not_started" {
// Observe once after delivery; NEVER repeat an uncertain write.
observation, origin, e := a.observe(observationArgs(args))
if e == nil && origin != nil {
src = origin
structured["observation"] = observation["structuredContent"]
r["content"] = append(items(r["content"]), items(observation["content"])...)
} else {
structured["observation_error"] = "Post-action evidence unavailable; inspect before another action."
}
}
if name == "verify_state" { // Read-only verification may include a screenshot; it is not a task verdict.
src = source(r, args, float64(time.Now().UnixMilli())/1000)
}
if src != nil {
rec["source"] = src
} else if name == "get_window_state" && r["isError"] != true {
return failure("capture target or geometry could not be validated", "not_started", "none", "invalid_capture")
}
if structured["effect"] == "refused" {
r["isError"] = true
}
structured["artemis"] = rec
r["structuredContent"] = structured
return r
}
func (a *adapter) save(args obj) obj {
root := os.Getenv("ARTEMIS_ARTIFACTS_DIR")
if root == "" {
return failure("task artifact directory unavailable", "not_started", "none", "missing_artifacts")
}
r, src, err := a.observe(observationArgs(args))
if err != nil || src == nil {
return failure("fresh window capture failed", "not_started", "none", "capture_failed")
}
var png []byte
for _, v := range items(r["content"]) {
b := object(v)
if b["type"] == "image" && b["mimeType"] == "image/png" {
png, _ = base64.StdEncoding.DecodeString(str(b["data"]))
break
}
}
if len(png) == 0 {
return failure("expected PNG capture", "not_started", "none", "capture_format")
}
if err = os.MkdirAll(root, 0700); err != nil {
return failure("artifact directory unavailable", "not_started", "none", "file_error")
}
path := filepath.Join(root, str(args["filename"]))
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return failure("file exists or cannot be created", "not_started", "none", "file_error")
}
n, writeErr := f.Write(png)
closeErr := f.Close()
actual, readErr := os.ReadFile(path)
if writeErr != nil || closeErr != nil || readErr != nil || n != len(png) || !bytes.Equal(actual, png) {
return failure("artifact write could not be verified", "partial", "possible", "file_error")
}
rec := receipt("completed", "confirmed", "")
rec["source"] = src
rec["attachments"] = []any{obj{"name": str(args["filename"]), "sha256": fmt.Sprintf("%x", sha256.Sum256(actual)), "media_type": "image/png"}}
object(r["structuredContent"])["artemis"] = rec
r["content"] = append(items(r["content"]), textBlock("Saved verified task attachment: "+str(args["filename"])))
return r
}
func serve(a *adapter, in io.Reader, out io.Writer) {
input := make(chan request)
go func() {
defer close(input)
defer a.peer.stop()
s := bufio.NewScanner(in)
s.Buffer(make([]byte, 65536), 2<<20)
for s.Scan() {
var q request
if json.Unmarshal(s.Bytes(), &q) == nil {
if q.Method == "notifications/cancelled" {
a.peer.stop()
continue
}
input <- q
}
}
}()
enc := json.NewEncoder(out)
for q := range input {
if len(q.ID) == 0 {
continue
}
var result any
var rpcErr any
switch q.Method {
case "initialize":
result = obj{"protocolVersion": "2024-11-05", "capabilities": obj{"tools": obj{}}, "serverInfo": obj{"name": "artemis-cua-driver", "version": "0.36.0"}}
case "ping":
result = obj{}
case "tools/list":
result = obj{"tools": a.tools}
case "tools/call":
result = a.handle(str(q.Params["name"]), object(q.Params["arguments"]))
default:
rpcErr = obj{"code": -32601, "message": "method not found"}
}
response := obj{"jsonrpc": "2.0", "id": q.ID}
if rpcErr != nil {
response["error"] = rpcErr
} else {
response["result"] = result
}
if enc.Encode(response) != nil {
return
}
}
}
func main() {
exe, err := os.Executable()
if err != nil {
os.Exit(1)
}
p, err := startPeer(filepath.Join(filepath.Dir(exe), "cua-driver.exe"))
if err != nil {
fmt.Fprintln(os.Stderr, "Cua startup failed:", err)
os.Exit(1)
}
defer p.stop()
serve(newAdapter(p), os.Stdin, os.Stdout)
}
+61
View File
@@ -0,0 +1,61 @@
package main
import "testing"
func TestUnknownAndRefusedAreNotConfirmed(t *testing.T) {
cases := []struct{ effect, execution, side string }{{"confirmed", "completed", "confirmed"}, {"partial", "partial", "confirmed"}, {"unverifiable", "completed", "possible"}, {"suspected_noop", "completed", "possible"}, {"refused", "not_started", "none"}, {"", "unknown", "possible"}}
for _, c := range cases {
r := actionReceipt(obj{"structuredContent": obj{"effect": c.effect}}, true)
if r["execution"] != c.execution || r["side_effect"] != c.side || r["retryable"] != false {
t.Fatalf("%s: %v", c.effect, r)
}
}
}
func TestAttachmentCannotEscape(t *testing.T) {
for _, s := range []string{"../out.png", "..\\out.png", "C:out.png", "a.png:stream", "", "a.png ", "a.jpg"} {
if validFilename(s) {
t.Fatal(s)
}
}
if !validFilename("留档.png") {
t.Fatal("plain Unicode PNG rejected")
}
}
func TestUnexposedAndMissingTargetRejected(t *testing.T) {
a := newAdapter(nil)
for _, c := range []struct {
name string
args obj
}{{"run_script", obj{}}, {"click", obj{"pid": 1, "window_id": 2, "scope": "desktop"}}, {"get_window_state", obj{"pid": 1}}, {"click", obj{"pid": 0, "window_id": 2}}} {
if a.validate(c.name, c.args) == nil {
t.Fatal(c)
}
}
}
func TestSupportedArguments(t *testing.T) {
a := newAdapter(nil)
if err := a.validate("click", obj{"pid": 1, "window_id": 2, "element_token": "s00000001:0"}); err != nil {
t.Fatal(err)
}
}
func TestBackgroundPreflightVsActuatorUncertainty(t *testing.T) {
r := obj{"isError": true, "structuredContent": obj{"code": "background_unavailable", "event_kind": "mouse_scroll"}}
if actionReceipt(r, true)["execution"] != "not_started" {
t.Fatal("preflight refusal must be explicit")
}
object(r["structuredContent"])["cause"] = "unknown actuator failure"
if actionReceipt(r, true)["execution"] != "unknown" {
t.Fatal("actuator uncertainty must not be hidden")
}
}
func TestClickModesAreDisjoint(t *testing.T) {
a := newAdapter(nil)
if a.validate("click", obj{"pid": 1, "window_id": 2, "element_token": "s1:0", "capture_id": "capture"}) == nil {
t.Fatal("mixed click modes")
}
if a.validate("click_at", obj{"pid": 1, "window_id": 2, "x": 5, "y": 5}) == nil {
t.Fatal("pixel action lacks capture")
}
}
+169
View File
@@ -0,0 +1,169 @@
{
"schema_version": 2,
"id": "cua-driver",
"name": "电脑控制 · Cua",
"version": "0.36.0",
"description": "通过 Cua Driver 的窗口截图与可访问性树操作 Windows 应用及浏览器窗口;复用 ARTEMIS 的任务调度、步骤证据与录像。",
"artemis_version": ">=2.0.0,<3.0.0",
"platforms": [
"win32"
],
"architectures": [
"amd64"
],
"source": "https://github.com/trycua/cua",
"license": "MIT",
"server": {
"transport": "stdio",
"command": "${plugin_dir}/bin/artemis-cua-adapter.exe"
},
"skill": "SKILL.md",
"targets": [
"computer",
"mixed"
],
"resources": [
"desktop:local"
],
"observation": {
"tool": "list_windows",
"arguments": {
"on_screen_only": true
},
"refresh_tools": [
"get_window_state"
]
},
"capabilities": {
"kind": "visual",
"recording": "windows",
"tools": {
"list_windows": {
"effect": "read",
"observation": true,
"idempotent": true,
"target_argument": "",
"cancellation": "unknown",
"evidence": [
"text"
]
},
"get_window_state": {
"effect": "read",
"observation": true,
"idempotent": true,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"verify_state": {
"effect": "read",
"observation": false,
"idempotent": true,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"click": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"drag": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"type_text": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"press_key": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"set_value": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"scroll": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
},
"save_screenshot": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image",
"file"
]
},
"click_at": {
"effect": "write",
"observation": false,
"idempotent": false,
"target_argument": "window_id",
"cancellation": "unknown",
"evidence": [
"text",
"image"
]
}
},
"required_tools": [
"list_windows",
"get_window_state",
"save_screenshot"
]
}
}
+645
View File
@@ -0,0 +1,645 @@
[
{
"name": "list_windows",
"description": "List visible application windows with exact pid/window_id and physical bounds. Pick only the user-authorized target.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"app": {
"description": "Only windows of apps whose name contains this text (case-insensitive), or whose bundle id equals it. Prefer this or `pid` over an unfiltered call, which returns every window on the host.",
"type": "string"
},
"on_screen_only": {
"description": "When true, drop windows that aren't currently on-screen. Default false.",
"type": "boolean"
},
"pid": {
"description": "Optional pid filter. When set, only this pid's windows are returned.",
"type": "integer"
}
},
"type": "object",
"required": []
},
"annotations": {
"readOnlyHint": true,
"idempotentHint": true
}
},
{
"name": "get_window_state",
"description": "Read fresh window screenshot and compact UIA tree. Every [N] uses element_token=<snapshot_id>:N; a new snapshot invalidates old tokens. Pixel x/y are screenshot-local; use capture_id. since=latest returns changes. Keep the new snapshot_id.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"max_elements": {
"description": "Cap on the total number of UIA nodes walked. Truncates depth-first; markdown and structured elements truncate together. Default 250 (5 000 with full_output:true); the response states when the tree was cut. Raise it to read further into a la",
"minimum": 1,
"type": "integer"
},
"max_image_dimension": {
"description": "Per-call long-edge limit for the returned screenshot, in pixels (aspect ratio preserved). Explicit values override configured behavior; 0 returns native resolution. Omit to preserve the configured default.",
"minimum": 0,
"type": "integer"
},
"pid": {
"description": "Process ID from `list_apps`.",
"type": "integer"
},
"query": {
"description": "Optional case-insensitive substring. Projects both tree_markdown and structured elements to matches plus ancestors while preserving original indices. Compare total_element_count with returned_element_count.",
"type": "string"
},
"since": {
"description": "A `snapshot_id` from an earlier get_window_state of the same window, or \"latest\" for this window's most recent read with the same query/max_elements/max_depth. Use it on every re-read after acting. Returns only what changed against that sna",
"type": "string"
},
"window_id": {
"description": "HWND of the target window. Must belong to `pid`. Enumerate via `list_windows` or read from `launch_app`'s `windows` array.",
"type": "integer"
}
},
"required": [
"pid",
"window_id"
],
"type": "object"
},
"annotations": {
"readOnlyHint": true,
"idempotentHint": true
}
},
{
"name": "verify_state",
"description": "Read-only assertion for exact window. satisfied/unsatisfied/unknown describes the observation, not the whole task verdict.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"expect": {
"description": "One to eight predicates, combined with logical AND.",
"items": {
"additionalProperties": false,
"properties": {
"element": {
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"exists": {
"description": "Assert that at least one trusted element matches the selector.\n\nElement walks are not yet exhaustive on every platform, so absence\ncannot be proven. `false` is rejected instead of returning an\nindefinitely-unknown predicate.",
"type": "boolean"
},
"selected": {
"type": "boolean"
},
"selector": {
"additionalProperties": false,
"properties": {
"label_contains": {
"minLength": 1,
"type": "string"
},
"role": {
"minLength": 1,
"type": "string"
}
},
"required": [],
"type": "object"
},
"value_equals": {
"type": "string"
}
},
"required": [
"selector"
],
"type": "object"
},
"window": {
"additionalProperties": false,
"properties": {
"bounds": {
"additionalProperties": false,
"properties": {
"height": {
"type": "number"
},
"tolerance_px": {
"maximum": 100,
"minimum": 0,
"type": "number"
},
"width": {
"type": "number"
},
"x": {
"type": "number"
},
"y": {
"type": "number"
}
},
"required": [
"x",
"y",
"width",
"height"
],
"type": "object"
},
"exists": {
"type": "boolean"
}
},
"required": [],
"type": "object"
}
},
"required": [],
"type": "object"
},
"maxItems": 8,
"minItems": 1,
"type": "array"
},
"include_screenshot": {
"description": "Return the final window screenshot as image content for a multimodal\ncaller. The driver does not interpret that image.",
"type": "boolean"
},
"pid": {
"description": "Exact process whose window may be observed.",
"minimum": 1,
"type": "integer"
},
"stable_samples": {
"default": 2,
"description": "Consecutive satisfied samples required before returning success.",
"maximum": 5,
"minimum": 1,
"type": "integer"
},
"timeout_ms": {
"default": 5000,
"description": "Bounded wait. Zero performs one sample.",
"maximum": 10000,
"minimum": 0,
"type": "integer"
},
"window_id": {
"description": "Exact native window identifier.",
"type": "integer"
}
},
"required": [
"pid",
"window_id",
"expect"
],
"type": "object"
},
"annotations": {
"readOnlyHint": true,
"idempotentHint": true
}
},
{
"name": "click",
"description": "Click the latest UIA element_token. This tool has no pixel coordinates. For visual targets use click_at. Returns effect and fresh observation; old tokens become stale.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"button": {
"description": "Mouse button. Default \"left\".",
"enum": [
"left",
"right",
"middle"
],
"type": "string"
},
"count": {
"description": "Click count — 1 (single), 2 (double), 3 (triple). Default 1.",
"maximum": 3,
"minimum": 1,
"type": "integer"
},
"delivery_mode": {
"description": "Input delivery mode. 'background' (default) never swaps foreground: it routes through UIA Invoke / PostMessage and the window is never raised. For targets whose input stack silently drops posted events (Chromium/Electron content, GTK button",
"enum": [
"background",
"foreground"
],
"type": "string"
},
"element_token": {
"description": "Element handle `<snapshot_id>:<row>` from get_window_state: row `[11]` of a read whose snapshot_id is s0000002a is \"s0000002a:11\" (also in `structuredContent.elements[].element_token`). Returns an explicit stale error naming the current sna",
"pattern": "^s[0-9a-f]{8}:[0-9]+$",
"type": "string"
},
"pid": {
"description": "Target process ID for window scope. Optional with element_token (the token carries the pid). Omit with scope=desktop for screen-absolute coordinates from get_desktop_state.",
"type": "integer"
},
"window_id": {
"description": "HWND of the target window. Omit when element_token is supplied (the token carries it).",
"type": "integer"
}
},
"type": "object",
"required": [
"pid",
"window_id",
"element_token"
]
},
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
},
{
"name": "drag",
"description": "Drag between screenshot-local coordinates. Returns actual delivery effect and fresh post-action observation.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"delivery_mode": {
"description": "Input delivery mode. 'background' (default) never swaps foreground: it routes through UIA Invoke / PostMessage and the window is never raised. For targets whose input stack silently drops posted events (Chromium/Electron content, GTK button",
"enum": [
"background",
"foreground"
],
"type": "string"
},
"duration_ms": {
"description": "Wall-clock duration of drag path. Default: 500.",
"maximum": 10000,
"minimum": 0,
"type": "integer"
},
"from_x": {
"description": "Drag-start X in window-local screenshot pixels.",
"type": "number"
},
"from_y": {
"description": "Drag-start Y in window-local screenshot pixels.",
"type": "number"
},
"pid": {
"description": "Target process ID.",
"type": "integer"
},
"to_x": {
"description": "Drag-end X in window-local screenshot pixels.",
"type": "number"
},
"to_y": {
"description": "Drag-end Y in window-local screenshot pixels.",
"type": "number"
},
"window_id": {
"description": "Target window handle (HWND). Optional — driver picks frontmost window of pid when omitted.",
"type": "integer"
}
},
"required": [
"pid",
"window_id",
"from_x",
"from_y",
"to_x",
"to_y"
],
"type": "object"
},
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
},
{
"name": "type_text",
"description": "Type text into the focused target window or specified latest element. Fresh observation follows. If background is unsupported, explicitly choose foreground within task scope.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"delivery_mode": {
"description": "Input delivery mode. 'background' (default) never swaps foreground: it routes through UIA Invoke / PostMessage and the window is never raised. For targets whose input stack silently drops posted events (Chromium/Electron content, GTK button",
"enum": [
"background",
"foreground"
],
"type": "string"
},
"element_token": {
"description": "Opaque element handle from get_window_state. When supplied, type_text writes through UIA ValuePattern and reads that exact element back by handle. The shared ActionResult is confirmed only when the complete expected value is synchronously v",
"pattern": "^s[0-9a-f]{8}:[0-9]+$",
"type": "string"
},
"pid": {
"description": "Target process ID.",
"type": "integer"
},
"text": {
"description": "Text to insert at the focused element's cursor.",
"type": "string"
},
"window_id": {
"description": "HWND of the target window. Omit when element_token is supplied (the token carries it).",
"type": "integer"
},
"x": {
"description": "Window-local screenshot-pixel X of the field to type into — the element px action form. Pass x,y (no element_token) and the tool pixel-clicks there to establish real renderer focus, then types. Use for Chromium/Electron inputs the UIA/WM_CH",
"type": "number"
},
"y": {
"description": "Window-local screenshot-pixel Y of the field (see x).",
"type": "number"
}
},
"required": [
"pid",
"window_id",
"text"
],
"type": "object"
},
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
},
{
"name": "press_key",
"description": "Send key and modifiers to the exact window, optionally focus element_token or x/y first. Fresh observation follows.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"delivery_mode": {
"description": "Input delivery mode. 'background' (default) never swaps foreground: it routes through UIA Invoke / PostMessage and the window is never raised. For targets whose input stack silently drops posted events (Chromium/Electron content, GTK button",
"enum": [
"background",
"foreground"
],
"type": "string"
},
"element_token": {
"description": "Element handle `<snapshot_id>:<row>` from get_window_state: row `[11]` of a read whose snapshot_id is s0000002a is \"s0000002a:11\" (also in `structuredContent.elements[].element_token`). Returns an explicit stale error naming the current sna",
"pattern": "^s[0-9a-f]{8}:[0-9]+$",
"type": "string"
},
"key": {
"description": "Key name (return, tab, escape, up, down, left, right, space, delete, home, end, pageup, pagedown, f1-f12, letter, digit).",
"type": "string"
},
"modifiers": {
"description": "Optional modifier names held while the key is pressed (ctrl/shift/alt/win).",
"items": {
"type": "string"
},
"type": "array"
},
"pid": {
"description": "Target process ID.",
"type": "integer"
},
"window_id": {
"description": "HWND for the target window. Omit when element_token is supplied; otherwise auto-resolves the pid's first visible window.",
"type": "integer"
},
"x": {
"description": "Window-local screenshot-pixel X — the element px action form: pixel-click there to focus, then send the key. Use when the key must go to a Chromium/Electron surface the UIA path can't focus. Pass with y, no element_token.",
"type": "number"
},
"y": {
"description": "Window-local screenshot-pixel Y (see x).",
"type": "number"
}
},
"required": [
"pid",
"window_id",
"key"
],
"type": "object"
},
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
},
{
"name": "set_value",
"description": "Set the latest UIA element_token value. Returns delivery effect/readback and fresh screenshot/tree.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"delivery_mode": {
"description": "Input delivery mode. 'background' (default) never swaps foreground: it routes through UIA Invoke / PostMessage and the window is never raised. For targets whose input stack silently drops posted events (Chromium/Electron content, GTK button",
"enum": [
"background",
"foreground"
],
"type": "string"
},
"element_token": {
"description": "Element handle `<snapshot_id>:<row>` from get_window_state: row `[11]` of a read whose snapshot_id is s0000002a is \"s0000002a:11\" (also in `structuredContent.elements[].element_token`). Returns an explicit stale error naming the current sna",
"pattern": "^s[0-9a-f]{8}:[0-9]+$",
"type": "string"
},
"pid": {
"description": "Target process ID. Optional with element_token (the token carries the pid).",
"type": "integer"
},
"value": {
"description": "New value. UIA will coerce to the element's native type.",
"type": "string"
},
"window_id": {
"description": "HWND of the window. Omit when element_token is supplied (the token carries it).",
"type": "integer"
}
},
"required": [
"pid",
"window_id",
"value"
],
"type": "object"
},
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
},
{
"name": "scroll",
"description": "Scroll the target window/element at x,y. amount=1..50, by=line/page. Verify content changed in fresh returned observation.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"amount": {
"description": "Number of scroll ticks. Default 3.",
"maximum": 50,
"minimum": 1,
"type": "integer"
},
"by": {
"description": "Scroll granularity. Default: line.",
"enum": [
"line",
"page"
],
"type": "string"
},
"delivery_mode": {
"description": "Input delivery mode. 'background' (default) never swaps foreground: it routes through UIA Invoke / PostMessage and the window is never raised. For targets whose input stack silently drops posted events (Chromium/Electron content, GTK button",
"enum": [
"background",
"foreground"
],
"type": "string"
},
"direction": {
"description": "Scroll direction.",
"enum": [
"up",
"down",
"left",
"right"
],
"type": "string"
},
"element_token": {
"description": "Element handle `<snapshot_id>:<row>` from get_window_state: row `[11]` of a read whose snapshot_id is s0000002a is \"s0000002a:11\" (also in `structuredContent.elements[].element_token`). Returns an explicit stale error naming the current sna",
"pattern": "^s[0-9a-f]{8}:[0-9]+$",
"type": "string"
},
"pid": {
"description": "Target process ID for window scope. Optional with element_token (the token carries the pid). Omit with scope=desktop for screen-absolute coordinates from get_desktop_state.",
"type": "integer"
},
"window_id": {
"description": "HWND of the target window. Omit when element_token is supplied; otherwise auto-resolves the pid's first visible window.",
"type": "integer"
},
"x": {
"description": "With pid/window_id: window-local screenshot X used to target a nested scroll surface in foreground mode. Without pid/window_id: screen-absolute X for desktop scope. Must be paired with y.",
"type": "number"
},
"y": {
"description": "With pid/window_id: window-local screenshot Y used to target a nested scroll surface in foreground mode. Without pid/window_id: screen-absolute Y for desktop scope. Must be paired with x.",
"type": "number"
}
},
"required": [
"pid",
"window_id",
"direction"
],
"type": "object"
},
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
},
{
"name": "save_screenshot",
"description": "Save fresh window screenshot to this task trace. Plain .png filename only; no overwrite. Returns verified attachment hash, screenshot and latest tree.",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"pid": {
"type": "integer",
"minimum": 1
},
"window_id": {
"type": "integer",
"minimum": 1
},
"filename": {
"type": "string"
}
},
"required": [
"pid",
"window_id",
"filename"
]
},
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
},
{
"name": "click_at",
"description": "Click a visual target at x,y in the latest window screenshot, with its exact capture_id. Never pass an element token. Returns effect and fresh observation.",
"inputSchema": {
"additionalProperties": false,
"properties": {
"button": {
"description": "Mouse button. Default \"left\".",
"enum": [
"left",
"right",
"middle"
],
"type": "string"
},
"capture_id": {
"description": "Optional ID from get_window_state or get_desktop_state. When present, x and y are admitted against that exact capture and the ID is consumed before native dispatch.",
"minLength": 1,
"type": "string"
},
"count": {
"description": "Click count — 1 (single), 2 (double), 3 (triple). Default 1.",
"maximum": 3,
"minimum": 1,
"type": "integer"
},
"delivery_mode": {
"description": "Input delivery mode. 'background' (default) never swaps foreground: it routes through UIA Invoke / PostMessage and the window is never raised. For targets whose input stack silently drops posted events (Chromium/Electron content, GTK button",
"enum": [
"background",
"foreground"
],
"type": "string"
},
"pid": {
"description": "Target process ID for window scope. Optional with element_token (the token carries the pid). Omit with scope=desktop for screen-absolute coordinates from get_desktop_state.",
"type": "integer"
},
"window_id": {
"description": "HWND of the target window. Omit when element_token is supplied (the token carries it).",
"type": "integer"
},
"x": {
"description": "X in window-local screenshot pixels — same space as the PNG get_window_state returns. Must be provided together with y.",
"type": "number"
},
"y": {
"description": "Y in window-local screenshot pixels. Must be provided together with x.",
"type": "number"
}
},
"type": "object",
"required": [
"pid",
"window_id",
"x",
"y",
"capture_id"
]
},
"annotations": {
"readOnlyHint": false,
"idempotentHint": false
}
}
]
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"fmt"
"os/exec"
"syscall"
"unsafe"
)
var user32 = syscall.NewLazyDLL("user32.dll")
var kernel32 = syscall.NewLazyDLL("kernel32.dll")
func configureProcess(c *exec.Cmd) {
c.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
user32.NewProc("SetThreadDpiAwarenessContext").Call(^uintptr(3))
}
func windowOwned(pid, hwnd int64) bool {
var actual uint32
r, _, _ := user32.NewProc("IsWindow").Call(uintptr(hwnd))
if r == 0 {
return false
}
user32.NewProc("GetWindowThreadProcessId").Call(uintptr(hwnd), uintptr(unsafe.Pointer(&actual)))
return int64(actual) == pid
}
func verifyWindow(pid, hwnd int64, b obj) (obj, bool) {
if !windowOwned(pid, hwnd) {
return nil, false
}
var r struct{ L, T, R, B int32 }
code, _, _ := syscall.NewLazyDLL("dwmapi.dll").NewProc("DwmGetWindowAttribute").Call(uintptr(hwnd), 9, uintptr(unsafe.Pointer(&r)), unsafe.Sizeof(r))
if code != 0 {
return nil, false
}
// Cua uses DWM physical bounds; refuse evidence after a window move/resize.
if int64(r.L) != num(b["left"]) || int64(r.T) != num(b["top"]) || int64(r.R-r.L) != num(b["width"]) || int64(r.B-r.T) != num(b["height"]) {
return nil, false
}
metric := func(n uintptr) int64 { x, _, _ := user32.NewProc("GetSystemMetrics").Call(n); return int64(int32(x)) }
return obj{"left": metric(76), "top": metric(77), "width": metric(78), "height": metric(79)}, true
}
func ownProcess(pid int) (uintptr, error) {
job, _, e := kernel32.NewProc("CreateJobObjectW").Call(0, 0)
if job == 0 {
return 0, e
}
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION, Windows x64. Kill the entire owned
// child tree when the adapter dies, including Cua's UIA helper.
data := make([]byte, 144)
*(*uint32)(unsafe.Pointer(&data[16])) = 0x2000
ok, _, _ := kernel32.NewProc("SetInformationJobObject").Call(job, 9, uintptr(unsafe.Pointer(&data[0])), uintptr(len(data)))
h, _, _ := kernel32.NewProc("OpenProcess").Call(0x0101, 0, uintptr(pid))
if h == 0 || ok == 0 {
closeJob(job)
return 0, fmt.Errorf("cannot own driver process")
}
defer kernel32.NewProc("CloseHandle").Call(h)
ok, _, e = kernel32.NewProc("AssignProcessToJobObject").Call(job, h)
if ok == 0 {
closeJob(job)
return 0, e
}
return job, nil
}
func closeJob(h uintptr) {
if h != 0 {
kernel32.NewProc("CloseHandle").Call(h)
}
}
+115
View File
@@ -0,0 +1,115 @@
"""Build a self-contained Windows plugin from a hash-pinned Cua release."""
import argparse
import hashlib
import json
import os
import subprocess
import urllib.request
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "plugins/cua-driver"
UPSTREAM_VERSION = "0.36.0"
COMMIT = "6d6c734c127025fb39ad5d585a10382808b39dc1"
DIGEST = "f8c47024b626f4e0e70f8428d2998df39e4879110c5722a29d5fd03e367353ad"
URL = "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.36.0/cua-driver-rs-0.36.0-windows-x86_64-binary.zip"
def main():
parser = argparse.ArgumentParser(__doc__)
parser.add_argument("upstream_zip", type=Path)
parser.add_argument("--go", default="go")
parser.add_argument("--output", type=Path, default=ROOT / "artifacts")
args = parser.parse_args()
version = json.loads((SOURCE / "plugin.json").read_text("utf-8"))["version"]
if version.split("+", 1)[0] != UPSTREAM_VERSION:
raise ValueError("Plugin version must match the pinned Cua Driver version")
if hashlib.sha256(args.upstream_zip.read_bytes()).hexdigest() != DIGEST:
raise ValueError("Cua release archive hash mismatch")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
adapter = output / "artemis-cua-adapter.exe"
env = {
**os.environ,
"CGO_ENABLED": "0",
"GOOS": "windows",
"GOARCH": "amd64",
"GOTOOLCHAIN": "local",
}
for command in (
[args.go, "test", "./..."],
[
args.go,
"build",
"-trimpath",
"-buildvcs=false",
"-ldflags=-s -w",
"-o",
str(adapter),
".",
],
):
subprocess.run(command, cwd=SOURCE, env=env, check=True, timeout=180)
license_file = SOURCE / "LICENSE.upstream"
if not license_file.exists():
with urllib.request.urlopen(
f"https://raw.githubusercontent.com/trycua/cua/{COMMIT}/LICENSE.md",
timeout=30,
) as response:
license_file.write_bytes(response.read())
target = output / f"cua-driver-{version}-windows-amd64.zip"
with (
zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as dest,
zipfile.ZipFile(args.upstream_zip) as upstream,
):
for name in (
"plugin.json",
"SKILL.md",
"LICENSE.upstream",
"RELEASE.md",
"VERIFICATION.md",
):
dest.write(SOURCE / name, name)
for source in sorted(SOURCE.glob("*.go")):
dest.write(source, f"source/{source.name}")
for name in ("go.mod", "tools.json"):
dest.write(SOURCE / name, f"source/{name}")
dest.write(adapter, "bin/artemis-cua-adapter.exe")
for name in upstream.namelist():
# Only the release's executable runtime; no global installer/hooks.
if Path(name).name in {
"cua-driver.exe",
"cua-driver-uia.exe",
"cua-cursor-theme.exe",
"cua_driver_sdk.dll",
}:
dest.writestr(f"bin/{Path(name).name}", upstream.read(name))
dest.writestr(
"provenance.json",
json.dumps(
{
"source_commit": COMMIT,
"upstream_version": UPSTREAM_VERSION,
"plugin_version": version,
"adapter_revision": 1,
"prerelease": True,
"url": URL,
"sha256": DIGEST,
},
indent=2,
),
)
print(
json.dumps(
{
"archive": str(target),
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
}
)
)
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
param([Parameter(Mandatory=$true)][string]$StateFile, [string]$Title='ARTEMIS Plugin Benchmark')
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class BenchmarkDpi { [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr c); }'
[void][BenchmarkDpi]::SetThreadDpiAwarenessContext([IntPtr](-4))
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
$form.Text=$Title
$form.Size=New-Object System.Drawing.Size(880,660)
$form.StartPosition='Manual'
$form.Location=New-Object System.Drawing.Point(80,80)
$form.TopMost=$true
$form.Font=New-Object System.Drawing.Font('Segoe UI',11)
$state=@{pid=$PID;title=$Title;applied=$false;ssid='';night=$false;apply_count=0;invalid_apply=0;target_clicks=0;wrong_clicks=0;finished=$false;events=@()}
function Save-State {
$state | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StateFile -Encoding UTF8
}
function Log-Event([string]$name) {$state.events += @{event=$name;at=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()};Save-State}
$heading=New-Object System.Windows.Forms.Label
$heading.Text='Router test console - owned fixture';$heading.AutoSize=$true;$heading.Location=New-Object System.Drawing.Point(20,18)
$form.Controls.Add($heading)
$label=New-Object System.Windows.Forms.Label
$label.Text='Network name';$label.Location=New-Object System.Drawing.Point(20,60);$label.AutoSize=$true;$form.Controls.Add($label)
$networkInput=New-Object System.Windows.Forms.TextBox
$networkInput.AccessibleName='Network name';$networkInput.Location=New-Object System.Drawing.Point(170,56);$networkInput.Size=New-Object System.Drawing.Size(280,32);$form.Controls.Add($networkInput)
$check=New-Object System.Windows.Forms.CheckBox
$check.Text='Night mode';$check.AccessibleName='Night mode';$check.Location=New-Object System.Drawing.Point(470,56);$check.Size=New-Object System.Drawing.Size(140,32);$form.Controls.Add($check)
$apply=New-Object System.Windows.Forms.Button
$apply.Text='Apply';$apply.AccessibleName='Apply';$apply.Location=New-Object System.Drawing.Point(630,54);$apply.Size=New-Object System.Drawing.Size(130,36);$form.Controls.Add($apply)
$status=New-Object System.Windows.Forms.Label
$status.Text='Not applied';$status.AutoSize=$true;$status.Location=New-Object System.Drawing.Point(20,104);$form.Controls.Add($status)
$apply.Add_Click({$state.apply_count++;$state.ssid=$networkInput.Text;$state.night=$check.Checked;$state.applied=($networkInput.Text -eq 'ARTEMIS-LAB' -and $check.Checked);if(!$state.applied){$state.invalid_apply++};$status.Text="Saved: $($networkInput.Text); Night mode: $($check.Checked)";Log-Event 'apply'})
$hint=New-Object System.Windows.Forms.Label
$hint.Text='Visual task: click the BLUE circle only, then scroll the panel to Finish.';$hint.AutoSize=$true;$hint.Location=New-Object System.Drawing.Point(20,150);$form.Controls.Add($hint)
$canvas=New-Object System.Windows.Forms.Panel
$canvas.Location=New-Object System.Drawing.Point(20,185);$canvas.Size=New-Object System.Drawing.Size(760,100);$canvas.BackColor=[System.Drawing.Color]::White;$form.Controls.Add($canvas)
$canvas.Add_Paint({param($sender,$e) for($i=0;$i -lt 8;$i++){$color=if($i -eq 5){[System.Drawing.Brushes]::RoyalBlue}else{[System.Drawing.Brushes]::IndianRed};$e.Graphics.FillEllipse($color,25+90*$i,24,34,34)}})
$canvas.Add_MouseClick({param($sender,$e) $hit=$false;for($i=0;$i -lt 8;$i++){if([Math]::Pow($e.X-(42+90*$i),2)+[Math]::Pow($e.Y-41,2) -le 289){$hit=$true;if($i -eq 5){$state.target_clicks++;$hint.Text='Blue target selected. Scroll panel to Finish.';Log-Event 'target'}else{$state.wrong_clicks++;Log-Event 'wrong-target'}}};if(!$hit){$state.wrong_clicks++;Log-Event 'miss'}})
$panel=New-Object System.Windows.Forms.Panel
$panel.AccessibleName='Scrollable settings';$panel.Location=New-Object System.Drawing.Point(20,310);$panel.Size=New-Object System.Drawing.Size(760,260);$panel.AutoScroll=$true;$panel.BorderStyle='FixedSingle';$form.Controls.Add($panel)
for($i=0;$i -lt 12;$i++){$row=New-Object System.Windows.Forms.Label;$row.Text="Diagnostic row $($i+1) - scroll down for Finish";$row.AutoSize=$true;$row.Location=New-Object System.Drawing.Point(15,(15+60*$i));$panel.Controls.Add($row)}
$finish=New-Object System.Windows.Forms.Button
$finish.Text='Finish';$finish.AccessibleName='Finish';$finish.Location=New-Object System.Drawing.Point(20,760);$finish.Size=New-Object System.Drawing.Size(160,40);$panel.Controls.Add($finish)
$finish.Add_Click({$state.scroll_value=$panel.VerticalScroll.Value;$state.finished=($state.target_clicks -eq 1 -and $state.wrong_clicks -eq 0 -and $state.scroll_value -gt 0);$finish.Text=if($state.finished){'Finished successfully'}else{'Target not selected'};Log-Event 'finish'})
$timer=New-Object System.Windows.Forms.Timer
$timer.Interval=900000;$timer.Add_Tick({$form.Close()})
$form.Add_Shown({Save-State;$timer.Start()})
try{[System.Windows.Forms.Application]::Run($form)}finally{$timer.Dispose();$form.Dispose()}
+279
View File
@@ -0,0 +1,279 @@
"""Opt-in sequential A/B through ARTEMIS's existing queue, never a second agent.
Usage: python tests/benchmark_plugins.py --traces <ARTEMIS traces> --output <new folder>
The user-started service must already run. Only this script's owned fixture is closed.
"""
import argparse
import json
import sqlite3
import subprocess
import time
import urllib.request
import uuid
from pathlib import Path
PROMPT = """只操作测试窗口 ARTEMIS Plugin Benchmark(进程 PID {pid}),不要观察或操作其他应用。先按此 PID 找到窗口。
1. 将 Network name 填为 ARTEMIS-LAB,勾选 Night mode,点击 Apply,确认显示 Saved: ARTEMIS-LAB; Night mode: True。
2. 只点击唯一的蓝色圆形一次,不要点击红色圆形。在下方 Scrollable settings 面板向下滚动,找到并点击 Finish,确认 Finished successfully。
3. 保存这个窗口的截图到任务 trace,文件名 proof.png,然后结束并如实说明实际结果。不要关闭窗口。
"""
def api(base, route, body=None):
request = urllib.request.Request(
base + route,
data=json.dumps(body).encode() if body is not None else None,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
def collect(traces, sid):
with sqlite3.connect(
f"file:{(traces / 'data_engine.db').as_posix()}?mode=ro", uri=True
) as db:
db.row_factory = sqlite3.Row
session = dict(
db.execute("SELECT * FROM sessions WHERE session_id=?", (sid,)).fetchone()
)
events = [
dict(row)
for row in db.execute(
"SELECT name,type,status,duration,payload FROM traces WHERE session_id=? ORDER BY timestamp",
(sid,),
)
]
steps = db.execute(
"SELECT count(*) FROM steps WHERE session_id=?", (sid,)
).fetchone()[0]
usage, calls, requests = [], [], []
for event in events:
payload = json.loads(event["payload"] or "{}")
if event["name"] == "llm_usage":
usage.append(payload)
if "plugin_id" in payload and "tool_name" in payload:
# Tool trace contains the real call receipt, not the separate log line.
calls.append(
{
"name": payload["tool_name"],
"status": event["status"],
"duration": event["duration"],
"args": payload.get("args"),
"result": payload.get("result"),
}
)
if event["name"] == "llm_request":
requests.append(payload)
return {
"session": session,
"steps": steps,
"llm_usage": usage,
"plugin_calls": calls,
"requests": requests,
}
def main():
parser = argparse.ArgumentParser(__doc__)
parser.add_argument("--base", default="http://127.0.0.1:8000")
parser.add_argument("--traces", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--model", default="mimo-v2.6-flash")
parser.add_argument("--repetitions", type=int, default=3)
parser.add_argument("--resume", action="store_true")
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=args.resume)
config = {
"model": args.model,
"reasoning_effort": "high",
"execution_target": "computer",
"record_video": True,
"recording": {"codec": "h264", "computer_scope": "window"},
"analyze_video": False,
"timeout_seconds": 480,
"request_timeout_seconds": 120,
"max_retries": 1,
}
protocol = {
"prompt": PROMPT,
"config": config,
"repetitions": args.repetitions,
"acceptance": "applied && finished && exactly one target click && no wrong clicks && verified proof.png",
"order": "OCU,Cua / Cua,OCU / OCU,Cua",
"fixture": "benchmark-fixture.ps1",
}
protocol_path = args.output / "protocol.json"
if args.resume and protocol_path.exists():
if json.loads(protocol_path.read_text("utf-8")) != protocol:
raise ValueError(
"Resume requires the original prompt, model and task settings"
)
else:
protocol_path.write_text(
json.dumps(protocol, ensure_ascii=False, indent=2), "utf-8"
)
for repetition in range(args.repetitions):
order = (
["open-computer-use", "cua-driver"]
if repetition % 2 == 0
else ["cua-driver", "open-computer-use"]
)
for plugin in order:
folder = args.output / f"{repetition + 1}-{plugin}"
if args.resume and (folder / "result.json").is_file():
continue
queue = api(args.base, "/api/status")
if queue.get("active_tasks") or queue.get("queue"):
raise RuntimeError(
"Other tasks are active/queued; benchmark will not interfere"
)
folder.mkdir()
state_file = folder / "fixture.json"
stderr = (folder / "fixture-stderr.log").open("w")
fixture = subprocess.Popen(
[
"powershell.exe",
"-NoProfile",
"-File",
str(Path(__file__).with_name("benchmark-fixture.ps1").resolve()),
"-StateFile",
str(state_file.resolve()),
],
creationflags=subprocess.CREATE_NO_WINDOW,
stdout=subprocess.DEVNULL,
stderr=stderr,
)
sid = str(uuid.uuid4())
done = False
try:
for _ in range(100):
if state_file.exists():
break
time.sleep(0.1)
if not state_file.exists():
raise RuntimeError("fixture did not start")
body = {
"goal": PROMPT.format(pid=fixture.pid),
"session_id": sid,
"profile": "flash",
"ingress": "client",
"runtime_overrides": {
**config,
"plugins": [
{
"id": plugin,
"profile": "benchmark"
if plugin == "cua-driver"
else "default",
"version": "0.1.2"
if plugin == "cua-driver"
else "1.1.0",
}
],
},
}
(folder / "request.json").write_text(
json.dumps(body, ensure_ascii=False, indent=2), "utf-8"
)
started = time.monotonic()
response = api(args.base, "/api/run", body)
(folder / "submission.json").write_text(json.dumps(response), "utf-8")
print(
json.dumps(
{
"event": "submitted",
"plugin": plugin,
"round": repetition + 1,
"trace_id": sid,
"response": response,
}
),
flush=True,
)
if response.get("status") in {"rejected", "error"}:
raise RuntimeError("task was rejected")
while time.monotonic() - started < 600:
time.sleep(15)
status = api(args.base, f"/api/sessions/{sid}")
state = json.loads(state_file.read_text("utf-8-sig"))
print(
json.dumps(
{
"event": "progress",
"trace_id": sid,
"status": status["status"],
"elapsed": round(time.monotonic() - started),
"applied": state["applied"],
"finished": state["finished"],
}
),
flush=True,
)
if status["status"] not in {
"running",
"pending",
"queued",
"stopping",
"starting",
}:
done = True
break
if not done:
api(args.base, "/api/stop", {"session_id": sid})
time.sleep(3)
elapsed = time.monotonic() - started
result = collect(args.traces.resolve(), sid)
state = json.loads(state_file.read_text("utf-8-sig"))
attachment = (
args.traces / sid / "artifacts/plugins" / plugin / "proof.png"
)
result.update(
trace_id=sid,
plugin=plugin,
repetition=repetition + 1,
fixture=state,
observed_elapsed=elapsed,
watchdog_timeout=not done,
actual_pass=bool(
state["applied"]
and state["finished"]
and state["target_clicks"] == 1
and state["wrong_clicks"] == 0
and attachment.exists()
),
attachment_exists=attachment.exists(),
)
(folder / "result.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2), "utf-8"
)
print(
json.dumps(
{
"event": "done",
"trace_id": sid,
"actual_pass": result["actual_pass"],
}
),
flush=True,
)
# Release/recording cleanup belongs to ARTEMIS, wait before next fixture.
for _ in range(15):
if not api(args.base, "/api/status").get("active_tasks"):
break
time.sleep(2)
finally:
if not done:
try:
api(args.base, "/api/stop", {"session_id": sid})
except (OSError, ValueError) as error:
print(f"Cleanup request failed for {sid}: {error}", flush=True)
if fixture.poll() is None:
fixture.terminate()
fixture.wait(10)
stderr.close()
if __name__ == "__main__":
main()
+7 -1
View File
@@ -1,4 +1,8 @@
param([string]$Title, [string]$ReadyFile, [switch]$Cover, [int]$Width=500, [int]$Height=300)
param([string]$Title, [string]$ReadyFile, [switch]$Cover, [int]$Width=500, [int]$Height=300, [switch]$PerMonitorDpi)
if ($PerMonitorDpi) {
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class FixtureDpi { [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr c); }'
[void][FixtureDpi]::SetThreadDpiAwarenessContext([IntPtr](-4))
}
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
@@ -20,6 +24,8 @@ $button.Location = New-Object System.Drawing.Point(100, 100)
$button.Size = New-Object System.Drawing.Size(180, 50)
$button.BackColor = [System.Drawing.Color]::Red
$button.Add_Click({ [System.IO.File]::WriteAllText(($ReadyFile + '.clicked'), 'clicked') })
$button.Add_MouseDown({ param($sender, $event) [System.IO.File]::AppendAllText(($ReadyFile + '.mouse'), "down:$($event.X),$($event.Y)`n") })
$button.Add_MouseUp({ param($sender, $event) [System.IO.File]::AppendAllText(($ReadyFile + '.mouse'), "up:$($event.X),$($event.Y)`n") })
$form.Controls.Add($button)
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 120000
+115
View File
@@ -0,0 +1,115 @@
"""Validate the benchmark's independent oracle and Cua foreground escalation."""
import asyncio
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from artemis.plugins.models import ServerConfig
from artemis.plugins.session import PluginSession
async def main(executable):
with tempfile.TemporaryDirectory(
prefix="artemis-benchmark-validation-"
) as temporary:
state_file = Path(temporary) / "state.json"
proc = await asyncio.create_subprocess_exec(
"powershell.exe",
"-NoProfile",
"-File",
str(Path(__file__).with_name("benchmark-fixture.ps1")),
"-StateFile",
str(state_file),
creationflags=subprocess.CREATE_NO_WINDOW,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
session = None
try:
for _ in range(100):
if state_file.exists():
break
await asyncio.sleep(0.1)
assert state_file.exists()
session = await PluginSession(
ServerConfig(command=str(executable)), executable.parent
).start()
listing = await session.call("list_windows", {"pid": proc.pid})
target = {
"pid": proc.pid,
"window_id": listing.structuredContent["windows"][0]["window_id"],
}
async def element(label, tool, **extra):
state = await session.call("get_window_state", target)
text = "\n".join(getattr(c, "text", "") for c in state.content)
match = re.search(r'\[(\d+)\][^\n]*"' + re.escape(label) + '"', text)
assert match, text
token = state.structuredContent["snapshot_id"] + ":" + match[1]
result = await session.call(
tool, {**target, "element_token": token, **extra}
)
assert not result.isError, result
await element("Network name", "set_value", value="ARTEMIS-LAB")
await element("Night mode", "click")
await element("Apply", "click")
assert json.loads(state_file.read_text("utf-8-sig"))["applied"]
state = await session.call("get_window_state", target)
pixel = await session.call(
"click_at",
{
**target,
"x": 512,
"y": 256,
"capture_id": state.structuredContent["capture_id"],
},
)
assert not pixel.isError, pixel
scrolled = await session.call(
"scroll",
{
**target,
"direction": "down",
"amount": 4,
"by": "page",
"x": 400,
"y": 470,
},
)
if scrolled.isError:
assert (
scrolled.structuredContent["artemis"]["execution"] == "not_started"
), scrolled
scrolled = await session.call(
"scroll",
{
**target,
"direction": "down",
"amount": 4,
"by": "page",
"x": 400,
"y": 470,
"delivery_mode": "foreground",
},
)
assert not scrolled.isError, scrolled
await element("Finish", "click")
result = json.loads(state_file.read_text("utf-8-sig"))
print(json.dumps(result))
assert (
result["applied"] and result["finished"] and result["wrong_clicks"] == 0
)
finally:
if session:
await session.aclose()
proc.terminate()
await asyncio.wait_for(proc.wait(), 10)
if __name__ == "__main__":
asyncio.run(main(Path(sys.argv[1]).resolve()))
+141
View File
@@ -0,0 +1,141 @@
"""Opt-in actual HWND capture, action, artifact, recording and shutdown proof."""
import asyncio
import json
import subprocess
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
from artemis.config.agent import RecordingConfig
from artemis.plugins.evidence import (
decode_receipt,
persist_attachments,
source_identity,
validate_tools,
)
from artemis.plugins.models import PluginCallResult, PluginManifest, ServerConfig
from artemis.plugins.recording import PluginRecordings
from artemis.plugins.session import PluginSession
async def run(executable, output):
output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="artemis-cua-smoke-") as temporary:
ready = Path(temporary) / "ready"
proc = await asyncio.create_subprocess_exec(
"powershell.exe",
"-NoProfile",
"-File",
str(Path(__file__).with_name("capture-fixture.ps1")),
"-Title",
"ARTEMIS Cua Smoke",
"-ReadyFile",
str(ready),
"-PerMonitorDpi",
creationflags=subprocess.CREATE_NO_WINDOW,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
session = recorder = None
try:
for _ in range(100):
if ready.exists():
break
await asyncio.sleep(0.1)
assert ready.exists()
artifacts = output / "artifacts/plugins/cua-driver"
session = await PluginSession(
ServerConfig(
command=str(executable),
env={"ARTEMIS_ARTIFACTS_DIR": str(artifacts)},
),
executable.parent,
).start()
manifest = PluginManifest.model_validate_json(
(
Path(__file__).parents[1] / "plugins/cua-driver/plugin.json"
).read_text("utf-8")
)
validate_tools(manifest, session.tools)
windows = await session.call("list_windows", {"pid": proc.pid})
target = {
"pid": proc.pid,
"window_id": windows.structuredContent["windows"][0]["window_id"],
}
raw = await session.call("get_window_state", target)
assert not raw.isError, raw
outcome = PluginCallResult(
status="success", structured_content=raw.structuredContent
)
decode_receipt(outcome)
assert outcome.source and outcome.source.width > 0
snapshot = raw.structuredContent["snapshot_id"]
clicked = await session.call(
"click", {**target, "element_token": f"{snapshot}:0"}
)
assert not clicked.isError, clicked
assert Path(str(ready) + ".clicked").exists()
assert clicked.structuredContent["artemis"]["source"]
assert clicked.structuredContent["observation"]["snapshot_id"] != snapshot
saved = await session.call(
"save_screenshot", {**target, "filename": "cua-proof.png"}
)
assert not saved.isError, saved
receipt = saved.structuredContent["artemis"]
files = persist_attachments(output, "cua-driver", receipt["attachments"])
duplicate = await session.call(
"save_screenshot", {**target, "filename": "cua-proof.png"}
)
assert (
duplicate.isError
and duplicate.structuredContent["artemis"]["execution"] == "not_started"
)
context = SimpleNamespace(
data_engine=SimpleNamespace(
base_dir=output, session_start_time=time.time()
),
agent_config=SimpleNamespace(
recording=RecordingConfig(codec="h264", computer_scope="window")
),
execution_setup=SimpleNamespace(
record_video_enabled=True, video_recording_tools_enabled=False
),
)
recorder = PluginRecordings(context)
await recorder.observe(
source_identity("cua-driver", outcome.source),
"cua-driver",
outcome.source,
)
assert recorder.active
await asyncio.sleep(5)
await recorder.stop()
clips = [
s for track in recorder.sources.values() for s in track["segments"]
]
assert clips and clips[0]["duration"] >= 3
result = {
"actual_click": True,
"source": outcome.source.model_dump(),
"attachments": files,
"recordings": clips,
}
(output / "result.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2), "utf-8"
)
print(json.dumps(result, ensure_ascii=False))
finally:
if recorder:
await recorder.stop()
if session:
await session.aclose()
proc.terminate()
await asyncio.wait_for(proc.wait(), 10)
if __name__ == "__main__":
import sys
asyncio.run(run(Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve()))
+208
View File
@@ -0,0 +1,208 @@
"""Summarize measured trace usage; never infer token counts from text length."""
import argparse
import hashlib
import json
import sqlite3
from collections import Counter, defaultdict
from pathlib import Path
from statistics import mean
def enrich(row, data, traces):
"""Read actual evidence separately; the fixture never feeds the agent."""
session_root = traces / row["trace_id"]
outcome = session_root / "run_outcome.json"
if outcome.exists():
value = json.loads(outcome.read_text("utf-8"))
row["ai_verdict"] = value.get("test_result", {}).get("status")
row["ai_verdict_reason"] = value.get("test_result", {}).get("reason")
row["outcome_code"] = value.get("code")
recordings = session_root / "evidence/recordings/recordings.json"
row["recordings"] = []
if recordings.exists():
for source in json.loads(recordings.read_text("utf-8")).get("sources", []):
row["recordings"].append(
{
"scope": source.get("scope"),
"status": source.get("status"),
"area": source.get("area"),
"segments": [
{
**segment,
"exists": (session_root / segment["file"]).is_file(),
}
for segment in source.get("segments", [])
],
}
)
proof = session_root / "artifacts/plugins" / row["plugin"] / "proof.png"
row["proof_sha256"] = (
hashlib.sha256(proof.read_bytes()).hexdigest() if proof.is_file() else None
)
start = data["session"]["start_time"]
finish = next(
(e["at"] / 1000 for e in data["fixture"]["events"] if e["event"] == "finish"),
None,
)
row["fixture_finish_seconds"] = (
round(finish - start, 3) if finish is not None else None
)
row["evidence_ready_seconds"] = (
round(max(finish, proof.stat().st_mtime) - start, 3)
if finish is not None and proof.is_file() and row["actual_pass"]
else None
)
row["post_evidence_seconds"] = (
round(row["seconds"] - row["evidence_ready_seconds"], 3)
if row["evidence_ready_seconds"] is not None
else None
)
with sqlite3.connect(
f"file:{(traces / 'data_engine.db').as_posix()}?mode=ro", uri=True
) as db:
errors = []
observed_plugins = set()
for status, payload in db.execute(
"SELECT status,payload FROM traces WHERE session_id=? AND type='tool' ORDER BY timestamp",
(row["trace_id"],),
):
value = json.loads(payload or "{}")
if value.get("plugin_id"):
observed_plugins.add(value["plugin_id"])
if value.get("plugin_id") and status != "success":
# Keep the diagnostic first line, not private window inventory.
message = str(value.get("error", "")).splitlines()
errors.append(
{
"plugin": value["plugin_id"],
"tool": value.get("tool_name"),
"message": message[0] if message else "",
}
)
row["tool_errors"] = errors
row["observed_plugins"] = sorted(observed_plugins)
def summarize(root, traces=None):
rows = []
for path in sorted(root.glob("*-*/result.json")):
data = json.loads(path.read_text("utf-8"))
usage = data["llm_usage"]
calls = data["plugin_calls"]
session = data["session"]
requests = data.get("requests", [])
usage_by_node = defaultdict(
lambda: {"calls": 0, "input": 0, "cached": 0, "output": 0}
)
models_from_usage = Counter()
for item in usage:
source_parts = item.get("source", "").split(":")
model_index = 2 if source_parts[0] == "lens" else 1
model = (
source_parts[model_index]
if len(source_parts) > model_index
else "unknown"
)
models_from_usage[model] += 1
node = usage_by_node[item.get("node", "unknown")]
node["calls"] += 1
node["input"] += item.get("prompt_tokens", 0)
node["cached"] += item.get("cached_tokens", 0)
node["output"] += item.get("completion_tokens", 0)
models = Counter(
p.get("model", "unknown") for p in requests if p.get("phase") == "completed"
)
input_tokens = sum(u.get("prompt_tokens", 0) for u in usage)
cached = sum(u.get("cached_tokens", 0) for u in usage)
output_tokens = sum(u.get("completion_tokens", 0) for u in usage)
duration = (session.get("end_time") or session["start_time"]) - session[
"start_time"
]
row = {
"plugin": data["plugin"],
"round": data["repetition"],
"trace_id": data["trace_id"],
"actual_pass": data["actual_pass"],
"execution_status": session["status"],
"input_tokens": input_tokens,
"cached_tokens": cached,
"uncached_input": input_tokens - cached,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"cache_rate": cached / input_tokens if input_tokens else None,
"llm_calls": len(usage),
"models": dict(models),
"models_from_usage": dict(models_from_usage),
"usage_by_node": dict(usage_by_node),
"request_phases": dict(Counter(p.get("phase") for p in requests)),
"request_seconds_by_role": {
role: round(
sum(
p.get("elapsed_seconds", 0)
for p in requests
if p.get("role") == role
),
3,
)
for role in sorted({p.get("role", "unknown") for p in requests})
},
"fallback_requests": sum(bool(p.get("is_fallback")) for p in requests),
"retried_requests": sum((p.get("attempt") or 1) > 1 for p in requests),
"executor_text_only_responses": sum(
p.get("role") == "FlashRunner"
and p.get("phase") == "completed"
and not p.get("tools")
for p in requests
),
"seconds": round(duration, 3),
"plugin_calls": len(calls),
"plugin_seconds": round(sum(c.get("duration") or 0 for c in calls), 3),
"plugin_errors": sum(c["status"] != "success" for c in calls),
"scroll_calls": sum(c["name"] == "scroll" for c in calls),
"fixture": data["fixture"],
}
if traces is not None:
enrich(row, data, traces)
rows.append(row)
aggregates = {}
for plugin in sorted({r["plugin"] for r in rows}):
group = [r for r in rows if r["plugin"] == plugin]
aggregate = {"runs": len(group), "passed": sum(r["actual_pass"] for r in group)}
for key in (
"input_tokens",
"cached_tokens",
"uncached_input",
"output_tokens",
"total_tokens",
"seconds",
"llm_calls",
"plugin_calls",
"plugin_seconds",
"plugin_errors",
"scroll_calls",
):
aggregate[f"mean_{key}"] = round(mean(r[key] for r in group), 3)
aggregate["cache_rate"] = sum(r["cached_tokens"] for r in group) / max(
1, sum(r["input_tokens"] for r in group)
)
aggregates[plugin] = aggregate
report = {"runs": rows, "aggregate": aggregates}
(root / "summary.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), "utf-8"
)
return report
if __name__ == "__main__":
parser = argparse.ArgumentParser(__doc__)
parser.add_argument("directory", type=Path)
parser.add_argument("--traces", type=Path)
options = parser.parse_args()
print(
json.dumps(
summarize(options.directory, options.traces)["aggregate"],
ensure_ascii=False,
indent=2,
)
)