“Make the entire npm your skill library.”
什么是 CodeAct?
简单地说你可以理解为只提供一个 tool,就是 exec。至于 exec 的是 Shell(执行CLI命令)、Python 还是 TypeScript,则取决于你的审美,都可以。而在 CyberGroupmate 中,我们基于 CodeAct 的执行思想,并进一步将他与 TypeScript 的特点结合,带来了本项目特有的渐进式披露(Progressive Disclosure)实现。
TS Skill 又是什么?
在常见的 Agent 架构中,要想“教会” Agent 一个新的技能,一般得靠 SKILL + MCP,而当面对复杂的外部系统(如 GitHub API、Notion API)时,得先将庞大的 SDK 再包装一层起来,弄出几十个散碎的 Tools 或者 CLI 命令,然后配一个 SKILL.md 说明书,这不仅增加了开发成本,还极易导致 LLM 的 ctx 被不一定用到的 SKILL 说明塞满,造成注意力分散。
TS Skills 是基于 TypeScript 类型声明文件和 JSDoc 注释的 Skill 系统,请看下面这个 PlayWright Skill 的例子:
/**
* playwright.d.ts — Playwright 浏览器自动化 Skill
*
* 通过环境变量配置(PLAYWRIGHT_MCP_* 系列),自动初始化 Playwright 浏览器实例。
* 返回的 Browser / BrowserContext / Page 均为完整的 Playwright 原生对象,
* 允许使用任何 Playwright 方法,包括但不限于:导航、点击、填写表单、截图、PDF 生成、
* 网络拦截、文件上传下载、多标签页操作、iframe 处理、设备模拟等。这里不一一列出。
* 完成任务记得关闭标签页,数据会自动保存。
*
*
* 如果执行出错,请自行参考完整的 Playwright API 文档: https://playwright.dev/docs/api/class-page
*/
declare const playwright: {
/**
* 获取已配置的 Browser 实例。
* 首次调用时会自动根据环境变量启动浏览器。
*
* @example
* const browser = await playwright.getBrowser();
* const contexts = browser.contexts();
*/
getBrowser(): Promise<import("playwright").Browser>;
/**
* 获取已配置的 BrowserContext 实例。
* 自动应用 viewport、device 模拟、proxy、权限等环境变量配置。
*
* @example
* const context = await playwright.getContext();
* await context.addCookies([{ name: "token", value: "abc", url: "https://example.com" }]);
*/
getContext(): Promise<import("playwright").BrowserContext>;
/**
* 获取可直接使用的 Page 实例。
* 这是最常用的入口:获取一个页面后可以调用任何 Playwright Page 方法。
* 如果已有页面则返回第一个,否则自动创建新页面。
*
* @example 基础导航与截图
* const page = await playwright.getPage();
* await page.goto("https://example.com");
* const title = await page.title();
* const screenshot = await page.screenshot({ path: "screenshot.png" });
*
* @example 表单填写与提交
* const page = await playwright.getPage();
* await page.goto("https://example.com/login");
* await page.fill("#username", "user");
* await page.fill("#password", "pass");
* await page.click("button[type=submit]");
* await page.waitForURL("<star><star>/dashboard");
*
* @example 提取页面数据
* const page = await playwright.getPage();
* await page.goto("https://example.com/data");
* // 使用 ariaSnapshot 提取页面的无障碍树作为结构化 YAML 返回
* const snapshot = await page.locator("body").ariaSnapshot();
* console.log(snapshot);
*/
getPage(): Promise<import("playwright").Page>;
/**
* 在同一 Context 中创建新的 Page 标签页。
*
* @example
* const page2 = await playwright.newPage();
* await page2.goto("https://example.com/other");
*/
newPage(): Promise<import("playwright").Page>;
/**
* 获取当前上下文中所有已打开的页面列表。
* 适合需要遍历或切换多个标签页时使用。
*
* @example
* const pages = await playwright.getPages();
* for (const p of pages) {
* console.log(await p.title());
* }
*/
getPages(): Promise<import("playwright").Page[]>;
/**
* 关闭浏览器并释放所有资源。
* CDP 模式下仅断开与远端浏览器的连接,不会关闭远端浏览器进程。
*
* @example
* await playwright.close();
*/
close(): Promise<void>;
};
要编写一个 TS Skill,得写两个文件:
index.ts(或者 index.js):Bootstrap入口,负责引入你想要用的 npm 包,然后读取 env 或者 config file,把提供的对象实例化。 .d.ts:索引文件,在这个文件里,不需要暴露完整的 npm 库接口,只暴露你希望 LLM 认知的接口,并配上简单易懂的 JSDoc。
d.ts 只是一个索引,不代表没写进 d.ts 里的方法就无法被调用。你会注意到 fill 、waitForURL 之类的方法并没有被定义,但是依然可以使用。在 Sandbox 中,Agent 的执行权限不会被限制,它可以随意使用实例化之后的对象里的方法。
这一切是基于一个假设:LLM 训练集里面包含了这个库的用法,LLM 会知道怎么写,d.ts 只是抛砖引玉。那万一这个库它没见过,或者就是写错了呢?请继续往下看。
渐进式文档披露
如果这是一个你自己写的库,有众多方法和属性,把整个复杂的 SDK 文档直接塞给 LLM,它多半会晕头转向。
我们在 CodeAct 两阶段执行机制)中引入了无状态的动态文档解析与注入:
Pass 1:注入单行签名
在 system prompt 中注入的可用 API 签名,只有方法名与简短说明:
## playwright
playwright.d.ts — 使用浏览器访问任何网站和URL(请优先首选使用) 通过环境变量配置(PLAYWRIGHT_MCP_* 系列),自动初始化 Playwright 浏览器实例。 返回的 Browser / BrowserContext / Page 均为完整的 Playwright 原生对象, 允许使用任何 Playwright 方法,包括但不限于:导航、点击、填写表单、截图、PDF 生成、 网络拦截、文件上传下载、多标签页操作、iframe 处理、设备模拟等。这里不一一列出。 完成任务记得关闭标签页,数据会自动保存。 如果执行出错,请自行参考完整的 Playwright API 文档: https://playwright.dev/docs/api/class-page
- getBrowser: 获取已配置的 Browser 实例。 首次调用时会自动根据环境变量启动浏览器。
- getContext: 获取已配置的 BrowserContext 实例。 自动应用 viewport、device 模拟、proxy、权限等环境变量配置。
- getPage: 获取可直接使用的 Page 实例。 这是最常用的入口:获取一个页面后可以调用任何 Playwright Page 方法。 如果已有页面则返回第一个,否则自动创建新页面。
- newPage: 在同一 Context 中创建新的 Page 标签页。
- getPages: 获取当前上下文中所有已打开的页面列表。 适合需要遍历或切换多个标签页时使用。
- close: 关闭浏览器并释放所有资源。 CDP 模式下仅断开与远端浏览器的连接,不会关闭远端浏览器进程。
由于这些定义很轻量,即便挂载了几十个 Skills,也不会占用太多的 Token
Pass2: 运行时错误后返回详细签名
当 LLM 准备采取行动,生成了如 await page.fill("#username", "user"); 的代码时,Sandbox 会先按原样执行。如果代码成功运行,系统不额外注入完整文档,避免把上下文撑大。
当代码出现运行时错误时,API Intent Extractor 会扫描刚刚执行失败的代码,提取其中调用的原生能力。
如果命中了一些复杂模块,系统会动态读取这些具体方法的完整类型定义(包含详尽的 JSDoc、多行说明、具体参数和示例),作为新的 observation 追加给 LLM。
这样就实现了意图推测。代价是一次错误的尝试和一点点可以被缓存的输入。当然,还需要加一点料:如果当前上下文里已经注入过这个文档了,就不会再注入了。
这其实很像是人在写代码的时候的状态,在前Vibe Coding时代,人一般先尝试根据编辑器通过 Language Server 获取的方法签名和注释写一下,错了之后再去读文档。
实践一下
以下基于一次任务,展示渐进式披露机制如何运作。
第一轮
system:
你是一个 Agent 助手,请编写 JS 代码来完成用户的要求,以下是你能使用的 API。
## tavily
tavily.d.ts — 网络搜索模块类型定义
- search: 搜索网页内容。
- extract: 从指定 URL 提取页面内容。
<省略其他 TS Skill 模块的API Brief>
user:
帮我看下《奥本海默》是什么时候上映的
没有参数列表,没有返回类型,没有示例代码——所有 Skill 模块加起来也只占很少的 Token。Agent 能同时看到 telegram、memory、github、finance、tavily 等十几个模块的概览,而上下文依然轻量清爽。
基于这份"目录",Agent 决定用tavily搜索一下相关信息,于是凭直觉盲写了一段代码:
const searchResults = await tavily.search(
"奥本海默 上映时间", { maxResults: 3 }
);
console.log("搜索结果:", JSON.stringify(searchResults, null, 2));
此时 Agent 并不知道 search() 的完整签名,它不知道有 searchDepth、topic、timeRange 这些选项,也不知道返回值的具体结构(result.results[0].url)。但它写出了 tavily.search 这个方法,这就足够 Harness 理解它需要什么了。
代码会先按原样执行。如果出现运行时错误,Harness 就会给出说明
运行错误之后
[📚 运行时错误后加载 API d.ts 文档]
刚才的代码出现了运行时错误,并且用到了以下 API: tavily.search。
以下是这些方法的完整类型定义和用法文档。请结合上面的错误信息修正代码:
### tavily.search
search(
query: string,
opts?: {
searchDepth?: "basic" | "advanced";
topic?: "general" | "news" | "finance";
maxResults?: number;
includeAnswer?: boolean;
...
},
): Promise<WebSearchResponse>
搜索网页内容。返回相关网页的标题、URL 和内容摘要。
示例:
const result = await tavily.search("2024年奥运会金牌榜");
console.log(result.answer);
console.log(result.results[0].url);
---
#### 相关类型定义
interface WebSearchResponse { results: WebSearchResult[]; answer?: string; ... }
...
第二轮
现在 Agent 手握完整手册,重新生成代码:
const searchResult = await tavily.search(
"CodeAct Progressive Disclosure MCP context stuffing",
{ searchDepth: "advanced", maxResults: 3 } // ← 现在知道有 searchDepth 选项了
);
console.log("搜索结果:", JSON.stringify(searchResult, null, 2));
对比两次输出:修正版代码利用了文档中新获知的 searchDepth: “advanced” 参数,生成了更精确的调用。
以上就是 CyberGroupmate 赛博群友 项目里面使用 CodeAct 和 TS Skill 的方法。之所以写这个,是因为有群友在部署的时候出现了困惑,所以我想着干脆一次把原理都说清楚,也方便大家(指挥自己的Agent)开发。
至于 赛博群友 是一个什么项目:这是一个由代码驱动的群聊社交 Agent,他/她能够以自然、拟人的行为模式参与群聊的自主 AI Agent。基于 CodeAct 范式构建,该 Agent 通过编写并执行 JS 代码和 Shell 命令来进行感知、推理和行动。跨群感知、人物群像记忆、和主动行动(私聊/群聊)是项目的根本设计目标。在这里不多花篇幅介绍,大家可以点进项目仓库了解。
这篇文章咕了很久,本来应该在今年大约五六月份的时候,在 CyberGroupmate 项目基本完成的那会一起写出来。但是因为各种各样的原因(一个是懒,第二个是忙,第三个是其实那时候我自己也没想明白什么是最重要、最值得拿来讲的东西),一直拖到了现在才动笔写。总之,感谢您的阅读,如有任何想法,欢迎留言讨论。