dsh-tabbit 0.2.3 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +70 -76
- package/client/client.js +390 -0
- package/cordis.patch.yml +76 -5
- package/lib/core/index.js +756 -0
- package/lib/installer/detect.js +374 -0
- package/lib/installer/download.js +247 -0
- package/lib/installer/index.js +254 -0
- package/lib/mentions/index.js +595 -0
- package/lib/permissions/index.js +136 -0
- package/lib/runtime/cli.js +229 -0
- package/lib/runtime/client.js +454 -0
- package/lib/runtime/codec.js +126 -0
- package/lib/runtime/endpoint.js +248 -0
- package/lib/runtime/errors.js +126 -0
- package/lib/runtime/instances.js +287 -0
- package/lib/runtime/net.js +143 -0
- package/lib/runtime/peer.js +132 -0
- package/lib/tool-browser/index.js +476 -0
- package/lib/update-check.js +343 -0
- package/lib/web-fetch/index.js +219 -0
- package/package.json +55 -16
- package/skills/tabbit/SKILL.md +66 -0
- package/skills/tabbit/references/interaction-helpers.md +150 -0
- package/skills/tabbit/references/platform-invocation.md +174 -0
- package/skills/{tabbit-browser → tabbit}/references/playwright-recipes.md +11 -3
- package/skills/tabbit/references/runtime-recovery.md +104 -0
- package/README.zh-CN.md +0 -114
- package/index.js +0 -352
- package/installer.js +0 -568
- package/skills/tabbit-browser/SKILL.md +0 -274
- package/skills/tabbit-browser/agents/openai.yaml +0 -4
- package/skills/tabbit-browser/references/interaction-helpers.md +0 -103
- package/skills/tabbit-browser/references/platform-invocation.md +0 -45
- package/skills/tabbit-browser/references/runtime-recovery.md +0 -95
- package/update-check.js +0 -177
- /package/skills/{tabbit-browser → tabbit}/references/information-extraction.md +0 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:`tabbit_browser` 工具——code-first 的真浏览器操作
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 本插件的门面。设计哲学是「一个工具、一个契约」(code-first):不做
|
|
7
|
+
* click/type/screenshot 一堆细粒度工具,而是让模型直接提交一段【async 函数体】
|
|
8
|
+
* ——在命名的 Tabbit 任务里以正版 Playwright 执行(用户真实 profile、真实
|
|
9
|
+
* 登录态),函数的返回值就是工具结果。这样模型能用它全部的 Playwright 知识,
|
|
10
|
+
* 一次调用干完"导航→等待→提取→加工"整套事,省去大量工具往返。
|
|
11
|
+
*
|
|
12
|
+
* 截图约定:模型在代码里 `page.screenshot({path: artifactPath('x.png')})` 存图,
|
|
13
|
+
* 然后返回 `{screenshots: [artifactPath('x.png')]}`——本文件识别这个顶层字段,
|
|
14
|
+
* 把图从磁盘读出来存成 dsh 图像附件,随工具结果一起进入模型上下文(模型
|
|
15
|
+
* 就能"看到"截图了)。artifactPath 是浏览器侧注入的全局函数,返回该任务
|
|
16
|
+
* 专属 artifacts 目录下的路径。
|
|
17
|
+
*
|
|
18
|
+
* ─── 必备背景:dsh 的工具注册机制 ────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* dsh 用 `ctx.tools.register(defineTool({...}))` 注册工具:
|
|
21
|
+
* - name/description:给模型看的工具名和说明(description 就是"给模型的
|
|
22
|
+
* 使用手册",写得好坏直接决定模型用得对不对);
|
|
23
|
+
* - parameters:参数的 JSON Schema(dsh 据此生成给模型的工具签名并校验);
|
|
24
|
+
* - output.schema {type:'json'}:声明结果是结构化 JSON;
|
|
25
|
+
* - output.render(args, value):把结果 JSON 渲染成实际进入模型上下文的
|
|
26
|
+
* 【内容块(ContentBlock)数组】——文本块+图像块,这是截图能"被看见"
|
|
27
|
+
* 的机关所在;
|
|
28
|
+
* - timeoutMs:dsh 对整个 execute 的超时(要 > 我们内部 CLI 的 145 秒超时,
|
|
29
|
+
* 否则 dsh 会先掐掉);
|
|
30
|
+
* - execute(args, exec):真正的执行体。exec 里有 agent(发起会话)、
|
|
31
|
+
* signal(取消信号,用户点停止时触发)等。
|
|
32
|
+
*/
|
|
33
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
34
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
35
|
+
import { TabbitCliError } from '../runtime/errors.js';
|
|
36
|
+
/*
|
|
37
|
+
* 工具说明(给模型看的使用手册)。要点:
|
|
38
|
+
* - 可用的注入全局:browser/context/page/pages()/usePage(p)/assert/expect/artifactPath(name);
|
|
39
|
+
* - 跑在用户真实 profile 里(登录态共享;开的标签页用户看得见,成组、不抢焦点);
|
|
40
|
+
* - 首次调用传 label 给标签组起个人话名字;
|
|
41
|
+
* - globalThis 跨调用持久(存状态别重复抓取);console.* 会被丢弃,只有
|
|
42
|
+
* 返回值能带信息出来;
|
|
43
|
+
* - 返回值要小且可 JSON 序列化;截图走 screenshots 约定;
|
|
44
|
+
* - 活干完的最后一次调用传 finish:true 关任务(别让标签组一直挂着);
|
|
45
|
+
* 该保留标签页时加 keep_tabs:true;
|
|
46
|
+
* - 会话内第一次动浏览器前先跑一次 tabbit_browser_install 预检、复杂操作前
|
|
47
|
+
* 先加载 tabbit skill。预检不是门禁(本工具不做任何强制),但没跑
|
|
48
|
+
* 过的话"没装/版本旧/服务没起来"只会在这里变成一次失败调用;而 install
|
|
49
|
+
* 工具能直接把安装包下下来。模型不加载 skill 就看不到这条协议,所以
|
|
50
|
+
* description 里也写一句。
|
|
51
|
+
*/
|
|
52
|
+
const DESCRIPTION = [
|
|
53
|
+
"Run JavaScript (an async function body) inside the user's Tabbit Browser via genuine Playwright.",
|
|
54
|
+
'Available globals: `browser`, `context`, `page` (current page), `pages()`, `usePage(p)`, `assert`, `expect`, `artifactPath(name)`.',
|
|
55
|
+
"Runs in the user's REAL profile with their logged-in sessions; tabs you open appear in their browser (grouped, non-focus-stealing). Pass `label` on your first call in a session to name that group something readable.",
|
|
56
|
+
'`globalThis` persists across calls in the same task — store state instead of re-scraping. `console.*` output is discarded; communicate via the return value only.',
|
|
57
|
+
'Return small JSON-serializable values. To show yourself a screenshot, save it with `page.screenshot({ path: artifactPath("x.png") })` and return `{ screenshots: [artifactPath("x.png")] }`.',
|
|
58
|
+
'Pass `finish: true` on your last call once this piece of browser work is done, so the task and its tab group close instead of sitting open indefinitely; add `keep_tabs: true` alongside it if the tabs should stay open regardless (e.g. tabs the user pointed you at).',
|
|
59
|
+
"Pass `list_tabs: true` (no code needed) to list every tab currently open in the user's browser — all windows, including tabs you did not open; metadata only, zero side effects. Use it to find a tab the user referred to, then attach that tab via `claim_tabs` (works whether the task is new or already exists).",
|
|
60
|
+
'Pass `list_tasks: true` (no code needed) to list the browser tasks this session already has open — a memory jog for when you have lost track after a gap in the conversation, before deciding whether to reuse one or start fresh.',
|
|
61
|
+
'Call `tabbit_browser_install` once before your first browser call in a session, and load the `tabbit` skill for recipes before non-trivial work.',
|
|
62
|
+
].join(' ');
|
|
63
|
+
/* 渲染进上下文的文本上限(30k 字符),防止巨型结果撑爆模型上下文。 */
|
|
64
|
+
const MAX_RENDERED_CHARS = 30_000;
|
|
65
|
+
/* 截图扩展名 → MIME 类型映射(attachments.saveImage 需要)。 */
|
|
66
|
+
const IMAGE_MEDIA_BY_EXT = {
|
|
67
|
+
png: 'image/png',
|
|
68
|
+
jpg: 'image/jpeg',
|
|
69
|
+
jpeg: 'image/jpeg',
|
|
70
|
+
webp: 'image/webp',
|
|
71
|
+
gif: 'image/gif',
|
|
72
|
+
};
|
|
73
|
+
/*
|
|
74
|
+
* 合法 artifact 路径的形状校验:必须落在某任务的 artifacts 目录里
|
|
75
|
+
* (…/tasks/task-<uuid>/artifacts/<文件名>)。这是安全边界——防止模型编造
|
|
76
|
+
* 任意路径(如 /etc/passwd、用户私人文件)骗我们读盘上传进上下文。
|
|
77
|
+
*/
|
|
78
|
+
const ARTIFACT_PATH_PATTERN = /\/tasks\/task-[0-9a-f-]+\/artifacts\/[^/]+$/u;
|
|
79
|
+
export const name = 'tabbit-tool-browser';
|
|
80
|
+
export const inject = ['tools', 'tabbit'];
|
|
81
|
+
export function apply(ctx) {
|
|
82
|
+
ctx.tools.register(defineTool({
|
|
83
|
+
name: 'tabbit_browser',
|
|
84
|
+
description: DESCRIPTION,
|
|
85
|
+
parameters: {
|
|
86
|
+
// code 在 schema 层不再强制(list_tabs:true 的调用没有代码可跑),
|
|
87
|
+
// 但常规调用缺了它会在 execute 里立即得到明确报错——不能让"忘传
|
|
88
|
+
// 代码"静默变成一次空求值。
|
|
89
|
+
code: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: 'Async-function body to evaluate in the browser task (Playwright APIs). Its return value is the tool result. Required unless list_tabs is true.',
|
|
92
|
+
},
|
|
93
|
+
// list_tabs:直连 Runtime Service 端点的全量标签页清单(含用户自开
|
|
94
|
+
// 页面)。零副作用(不建任务/不开页/不拉起浏览器),走
|
|
95
|
+
// ctx.tabbit.listAllTabs() 而非求值——所以离线时如实报离线。
|
|
96
|
+
list_tabs: {
|
|
97
|
+
type: 'boolean',
|
|
98
|
+
description: "List every tab of the user's running browser instead of running code: returns { tabCount, tabs: [{ tabId, windowId, index, title, url, active, state, group? }], ... }. state 'available' means the tab can be attached via claim_tabs (new task only); 'busy' means another automation task owns it. tabCount is always the full total; at most 100 entries are returned (listTruncated: true when clipped) — narrow with tabs_filter instead of re-listing. Zero side effects; the browser is never launched for a listing. Takes precedence over list_tasks if both are set. When set, code/task/finish are ignored.",
|
|
99
|
+
},
|
|
100
|
+
// tabs_filter:大浏览器(真机 170+ 标签页)上全量清单会顶到渲染截断线,
|
|
101
|
+
// 模型必须能按关键词收窄而不是反复重列。
|
|
102
|
+
tabs_filter: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
description: 'With list_tabs: case-insensitive substring matched against tab title and url, to find a specific tab in a large browser (e.g. the site name the user mentioned).',
|
|
105
|
+
},
|
|
106
|
+
// list_tasks:查这个会话自己已经开过哪些任务——纯内存读取会话任务
|
|
107
|
+
// 登记表(sessionTaskRegistry/defaultTaskNames),不碰 CLI 也不碰
|
|
108
|
+
// 浏览器。模型隔了几轮容易忘记自己还有任务开着,给它一个零成本的
|
|
109
|
+
// 记忆点,别每次都靠猜测复用任务名或者重开一个。
|
|
110
|
+
list_tasks: {
|
|
111
|
+
type: 'boolean',
|
|
112
|
+
description: "List the browser tasks this session currently has open, without touching the browser: returns { taskCount, defaultTask?, tasks: [{ task, isDefault }] }. defaultTask is the task an omitted `task` argument would target next. Use this when you've lost track of what you left open earlier in the conversation. Ignored if list_tabs is also set. When set, code/task/finish are ignored.",
|
|
113
|
+
},
|
|
114
|
+
// task:显式指定任务名(不同名字 = 完全独立的浏览器状态)。
|
|
115
|
+
// 一般不传,用会话默认任务即可。
|
|
116
|
+
task: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
description: 'Named task space. Omit to use the default per-session task (recommended). Distinct names = distinct browser state.',
|
|
119
|
+
},
|
|
120
|
+
// label:给默认任务(= 用户浏览器里的标签组)起人话名字。
|
|
121
|
+
// 只在"本会话第一次调用且没传 task"时生效——名字首调定型(见 core
|
|
122
|
+
// 的 defaultTaskFor),后续换 label 不会换名(换名=换任务=丢状态)。
|
|
123
|
+
label: {
|
|
124
|
+
type: 'string',
|
|
125
|
+
description: "Short human-readable description of what this browsing task is for (e.g. \"GitHub trending research\"), shown as the tab group's name in the user's browser. Only used when `task` is omitted AND this is the first tabbit_browser call in the session — later calls (in this session, still omitting `task`) keep the name already established and ignore a new label. Pass this on your first call whenever you can.",
|
|
126
|
+
},
|
|
127
|
+
// read_only:声明本次无副作用。中断后任务不会被隔离(quarantine),
|
|
128
|
+
// 恢复更安全省事。
|
|
129
|
+
read_only: {
|
|
130
|
+
type: 'boolean',
|
|
131
|
+
description: 'Declare that this call performs no mutations (safer recovery after interruptions).',
|
|
132
|
+
},
|
|
133
|
+
timeout_ms: {
|
|
134
|
+
type: 'integer',
|
|
135
|
+
description: 'Per-call evaluation timeout in milliseconds, max 120000 (default 120000).',
|
|
136
|
+
},
|
|
137
|
+
// claim_tabs:把用户【明确指给模型】的已有标签页认领进任务。两条路径:
|
|
138
|
+
// - 任务是本次调用【新建】的:走 evaluate() 自带的 --claim-tab(创建时
|
|
139
|
+
// 生效);
|
|
140
|
+
// - 任务【已经存在】(本会话此前用过,见 sessionTasks 判定):改走
|
|
141
|
+
// 独立的 claim 一次性子命令(client.ts 的 claimTabs())——真机
|
|
142
|
+
// 确认过这是顶层命令,不是只在任务创建那一刻才生效。
|
|
143
|
+
// 模型不需要关心走的是哪条路径,两边结果都会体现在返回值里
|
|
144
|
+
// (claimedTabCount/ownedPageCount 或 claimError)。
|
|
145
|
+
claim_tabs: {
|
|
146
|
+
type: 'array',
|
|
147
|
+
items: { type: 'integer' },
|
|
148
|
+
description: 'Tab ids EXPLICITLY provided by the user to attach their existing tabs to this task — works whether this call creates the task or the task already exists.',
|
|
149
|
+
},
|
|
150
|
+
// finish:本次调用后关闭任务(从任务列表消失;除非 keep_tabs,标签组
|
|
151
|
+
// 也一起关)。让模型按任务性质自己判断:一次性查询干完就关;用户可能
|
|
152
|
+
// 接着用的浏览器状态就留着。
|
|
153
|
+
finish: {
|
|
154
|
+
type: 'boolean',
|
|
155
|
+
description: "Close this task after this call: it stops appearing in the task list and (unless keep_tabs) its tab group closes. Set this on your LAST call for a piece of work whose browser state doesn't need to survive — judge by the task's nature: a one-off lookup or a completed multi-step job, yes; something the user might reasonably continue in the same browser context next message, no (leave unset).",
|
|
156
|
+
},
|
|
157
|
+
// keep_tabs:配合 finish 用。true = 只摘掉任务追踪、标签页留在浏览器里
|
|
158
|
+
// (对应 CLI 的 --keep;真机验证过 closedTabIds 为空数组)。
|
|
159
|
+
// 典型场景:任务里有用户自己指来的标签页(关了会吓到用户)。
|
|
160
|
+
keep_tabs: {
|
|
161
|
+
type: 'boolean',
|
|
162
|
+
description: 'Only with finish: true. Leave the tabs this task opened open in the browser instead of closing them. Prefer true when this task claimed tabs the user already had open (closing those would be surprising) or the user may want to keep reading/using what is on screen; otherwise omit (tabs close).',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
output: {
|
|
166
|
+
schema: { type: 'json' },
|
|
167
|
+
// 结果 JSON → 内容块(文本 + 截图图像块)的渲染,见 renderValue。
|
|
168
|
+
render: (_args, value) => renderValue(value),
|
|
169
|
+
},
|
|
170
|
+
// dsh 层的执行超时:160 秒 > 内部 CLI 子进程的 145 秒,保证内部超时
|
|
171
|
+
// 先触发、能给出更有含义的错误(而不是被 dsh 一刀切掐掉)。
|
|
172
|
+
timeoutMs: 160_000,
|
|
173
|
+
async execute(args, exec) {
|
|
174
|
+
const tabbit = ctx.tabbit;
|
|
175
|
+
// 极端情况下工具可能无所属 agent(如某些编排形态),用 'shared' 兜底。
|
|
176
|
+
// list_tasks 分支也要用它,故提到最前面统一算一次。
|
|
177
|
+
const agentId = exec.agent !== undefined ? String(exec.agent.id) : 'shared';
|
|
178
|
+
// list_tabs 分支:直连清单,与求值路径完全无关(不碰任务/agent/CLI)。
|
|
179
|
+
if (args.list_tabs === true) {
|
|
180
|
+
try {
|
|
181
|
+
const inventory = await tabbit.listAllTabs();
|
|
182
|
+
// 可选按关键词收窄(标题/URL 子串,不区分大小写)。
|
|
183
|
+
const filter = typeof args.tabs_filter === 'string' ? args.tabs_filter.toLowerCase() : '';
|
|
184
|
+
const matched = filter === ''
|
|
185
|
+
? inventory.tabs
|
|
186
|
+
: inventory.tabs.filter((tab) => tab.title.toLowerCase().includes(filter) || tab.url.toLowerCase().includes(filter));
|
|
187
|
+
// 真机教训(170 标签页):全量不限长会顶到 30k 渲染截断线,模型连
|
|
188
|
+
// 总数都读不到。三道防线:①总数字段放在列表【前面】(JSON 按插入
|
|
189
|
+
// 序渲染,截断也先保住总数);②列表封顶 100 条并显式 listTruncated;
|
|
190
|
+
// ③单条标题/URL 限长。
|
|
191
|
+
return {
|
|
192
|
+
status: 'succeeded',
|
|
193
|
+
tabCount: inventory.tabs.length,
|
|
194
|
+
...(filter !== '' ? { matchedCount: matched.length } : {}),
|
|
195
|
+
...(matched.length > 100 ? { listTruncated: true } : {}),
|
|
196
|
+
tabs: matched.slice(0, 100).map((tab) => ({
|
|
197
|
+
tabId: tab.tabId,
|
|
198
|
+
windowId: tab.windowId,
|
|
199
|
+
index: tab.index,
|
|
200
|
+
title: tab.title.slice(0, 80),
|
|
201
|
+
url: tab.url.slice(0, 160),
|
|
202
|
+
active: tab.active,
|
|
203
|
+
state: tab.state,
|
|
204
|
+
...(tab.group !== null ? { group: tab.group.title || tab.group.groupId } : {}),
|
|
205
|
+
})),
|
|
206
|
+
truncated: inventory.truncated,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (error instanceof TabbitCliError) {
|
|
211
|
+
return {
|
|
212
|
+
status: 'failed',
|
|
213
|
+
error: friendlyCliError(error),
|
|
214
|
+
errorCode: error.code,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// list_tasks 分支:会话任务登记表快照,纯内存读取(sessionTasks/
|
|
221
|
+
// currentDefaultTask 都不做 I/O),比 list_tabs 还更轻——连 Runtime
|
|
222
|
+
// Service 的被动端点都不连。
|
|
223
|
+
if (args.list_tasks === true) {
|
|
224
|
+
const tasks = tabbit.sessionTasks(agentId);
|
|
225
|
+
const defaultTask = tabbit.currentDefaultTask(agentId);
|
|
226
|
+
return {
|
|
227
|
+
status: 'succeeded',
|
|
228
|
+
taskCount: tasks.length,
|
|
229
|
+
...(defaultTask !== undefined ? { defaultTask } : {}),
|
|
230
|
+
tasks: tasks.map((task) => ({ task, isDefault: task === defaultTask })),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// 常规求值必须有代码(schema 层已放开 required,这里补明确报错)。
|
|
234
|
+
if (args.code === undefined || args.code === '') {
|
|
235
|
+
return {
|
|
236
|
+
status: 'failed',
|
|
237
|
+
error: 'code is required unless list_tabs or list_tasks is true',
|
|
238
|
+
errorCode: 'MISSING_CODE',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// 任务名决策:显式 task 优先,否则取会话默认任务(首调可被 label 定名)。
|
|
242
|
+
const task = args.task !== undefined && args.task !== '' ? args.task : tabbit.defaultTaskFor(agentId, args.label);
|
|
243
|
+
const client = tabbit.client();
|
|
244
|
+
const requestedClaims = args.claim_tabs !== undefined && args.claim_tabs.length > 0 ? args.claim_tabs : undefined;
|
|
245
|
+
// 本会话登记表里已经有这个任务名 → 一定是复用,创建时的 --claim-tab
|
|
246
|
+
// 语义上不会生效(evaluate 会撞见 reused=true 直接抛错)。改走独立的
|
|
247
|
+
// claim 子命令,成功/失败都不让整个调用失败,只把结果软性附加到
|
|
248
|
+
// 最终返回值——求值本身该跑还是照跑(模型可能只是顺手多认领一个
|
|
249
|
+
// 标签页,不代表这次调用的主要目的就是认领)。
|
|
250
|
+
let claimOutcome = {};
|
|
251
|
+
let claimAtCreation = requestedClaims;
|
|
252
|
+
if (requestedClaims !== undefined && tabbit.sessionTasks(agentId).includes(task)) {
|
|
253
|
+
claimAtCreation = undefined; // 已经在这里处理了,别再让 evaluate 撞 CLAIM_REQUIRES_NEW_TASK
|
|
254
|
+
try {
|
|
255
|
+
const claimed = await client.claimTabs(task, requestedClaims);
|
|
256
|
+
claimOutcome = {
|
|
257
|
+
claimedTabCount: requestedClaims.length,
|
|
258
|
+
...(claimed.ownedPageCount !== undefined ? { ownedPageCount: claimed.ownedPageCount } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
claimOutcome = {
|
|
263
|
+
claimError: error instanceof TabbitCliError ? friendlyCliError(error) : String(error?.message ?? error),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
let outcome;
|
|
268
|
+
try {
|
|
269
|
+
outcome = await client.evaluate({
|
|
270
|
+
task,
|
|
271
|
+
code: args.code,
|
|
272
|
+
...(args.read_only === true ? { readOnly: true } : {}),
|
|
273
|
+
...(args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {}),
|
|
274
|
+
...(claimAtCreation !== undefined ? { claimTabs: claimAtCreation } : {}),
|
|
275
|
+
signal: exec.signal, // 用户取消 → 一路传到 CLI 子进程被杀
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
// CLI 层错误:转成友好文案的失败【结果】返回(不抛异常)——模型
|
|
280
|
+
// 拿到结构化的 error + errorCode 能自行决定重试/换路,比一条裸异常
|
|
281
|
+
// 有用得多。非 CLI 错误(我们自己的 bug)照常抛出。
|
|
282
|
+
if (error instanceof TabbitCliError) {
|
|
283
|
+
return {
|
|
284
|
+
status: 'failed',
|
|
285
|
+
task,
|
|
286
|
+
error: friendlyCliError(error),
|
|
287
|
+
errorCode: error.code,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
// 求值成功(哪怕代码本身失败)说明任务确实存在过:登记
|
|
293
|
+
// "会话 × 任务 × 实际实例",供会话结束时在正确实例上清理
|
|
294
|
+
// (resolvedInstanceId 的意义见 runtime/client.ts)。
|
|
295
|
+
tabbit.rememberSessionTask(agentId, task, client.resolvedInstanceId());
|
|
296
|
+
// finish 分支:按请求关闭任务。失败不让整个调用报错——求值结果是
|
|
297
|
+
// 真实有效的,只把 finishError 如实附上(例:新版 Runtime Service 会
|
|
298
|
+
// 抢先自动 finalize 闲置任务,我们随后的 finish 会撞 INVALID_STATE,
|
|
299
|
+
// 这是服务端自身行为,属预期兜底)。
|
|
300
|
+
let finishOutcome = {};
|
|
301
|
+
if (args.finish === true) {
|
|
302
|
+
try {
|
|
303
|
+
await client.finishTask(task, { keep: args.keep_tabs === true });
|
|
304
|
+
// 关闭成功:从登记里除名 + 重置默认任务名(下次重新定名,见 core)。
|
|
305
|
+
tabbit.forgetTask(agentId, task);
|
|
306
|
+
finishOutcome = { finished: true };
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
finishOutcome = { finishError: `failed to close task: ${String(error?.message ?? error)}` };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// 求值失败(模型代码抛异常/超时等):组装失败结果。
|
|
313
|
+
if (outcome.status === 'failed') {
|
|
314
|
+
return {
|
|
315
|
+
status: 'failed',
|
|
316
|
+
task,
|
|
317
|
+
error: outcome.errorMessage ?? 'evaluation failed',
|
|
318
|
+
...(outcome.taskWasReset ? { taskWasReset: true } : {}),
|
|
319
|
+
...(outcome.notes.length > 0 ? { notes: outcome.notes } : {}),
|
|
320
|
+
...claimOutcome,
|
|
321
|
+
...finishOutcome,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
// 求值成功:把解码结果装进工具结果。三种形态:
|
|
325
|
+
// - undefined(无返回值)→ value: null;
|
|
326
|
+
// - rawText(截断/解析失败)→ resultText + truncated;
|
|
327
|
+
// - value(正常)→ value(可能带 truncated)。
|
|
328
|
+
const decoded = outcome.result;
|
|
329
|
+
const value = {
|
|
330
|
+
status: 'succeeded',
|
|
331
|
+
task,
|
|
332
|
+
...(outcome.taskWasReset ? { taskWasReset: true } : {}),
|
|
333
|
+
...(outcome.notes.length > 0 ? { notes: outcome.notes } : {}),
|
|
334
|
+
...claimOutcome,
|
|
335
|
+
...finishOutcome,
|
|
336
|
+
};
|
|
337
|
+
if (decoded === undefined) {
|
|
338
|
+
value.value = null;
|
|
339
|
+
}
|
|
340
|
+
else if (decoded.rawText !== undefined) {
|
|
341
|
+
value.resultText = decoded.rawText;
|
|
342
|
+
value.truncated = true;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
value.value = decoded.value ?? null;
|
|
346
|
+
if (decoded.truncated)
|
|
347
|
+
value.truncated = true;
|
|
348
|
+
}
|
|
349
|
+
// 截图约定处理:返回值里有合法的 screenshots 数组就逐张读盘转附件。
|
|
350
|
+
const screenshots = extractScreenshotPaths(value.value);
|
|
351
|
+
if (screenshots.length > 0) {
|
|
352
|
+
value.screenshots = await loadScreenshots(ctx, screenshots, exec.signal);
|
|
353
|
+
}
|
|
354
|
+
return value;
|
|
355
|
+
},
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
/*
|
|
359
|
+
* 从返回值里提取截图路径:必须是顶层 screenshots 字段、必须是字符串数组,
|
|
360
|
+
* 最多取前 8 张(防滥用)。形状不对就当没有,不报错。
|
|
361
|
+
*/
|
|
362
|
+
function extractScreenshotPaths(value) {
|
|
363
|
+
if (typeof value !== 'object' || value === null)
|
|
364
|
+
return [];
|
|
365
|
+
const raw = value.screenshots;
|
|
366
|
+
if (!Array.isArray(raw))
|
|
367
|
+
return [];
|
|
368
|
+
return raw.filter((item) => typeof item === 'string').slice(0, 8);
|
|
369
|
+
}
|
|
370
|
+
/*
|
|
371
|
+
* 把截图文件读进 dsh 附件系统。每张图独立成败(一张坏了不拖累别的),
|
|
372
|
+
* 逐张过四道检查:
|
|
373
|
+
* 1. 路径形状必须是任务 artifacts 目录(安全边界,见 ARTIFACT_PATH_PATTERN);
|
|
374
|
+
* 2. 扩展名必须是已知图像类型;
|
|
375
|
+
* 3. attachments 服务(ctx.get 软取,dsh-attachment 提供)得在场;
|
|
376
|
+
* 4. 文件体积不超过附件服务的图像上限(超了提示模型缩图/裁剪)。
|
|
377
|
+
* 全过 → attachments.saveImage 存成持久图像附件,返回附件引用。
|
|
378
|
+
*/
|
|
379
|
+
async function loadScreenshots(ctx, paths, signal) {
|
|
380
|
+
const attachments = ctx.get('attachments');
|
|
381
|
+
const records = [];
|
|
382
|
+
for (const path of paths) {
|
|
383
|
+
if (!ARTIFACT_PATH_PATTERN.test(path)) {
|
|
384
|
+
records.push({ path, error: 'not a task artifact path; only artifactPath(...) files can be attached' });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const extension = path.split('.').pop()?.toLowerCase() ?? '';
|
|
388
|
+
const mediaType = IMAGE_MEDIA_BY_EXT[extension];
|
|
389
|
+
if (mediaType === undefined) {
|
|
390
|
+
records.push({ path, error: 'unsupported image extension' });
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (attachments === undefined) {
|
|
394
|
+
records.push({ path, error: 'attachment service unavailable; screenshot left on disk' });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const info = await stat(path);
|
|
399
|
+
// 附件服务有两个上限:单图上限 + 单条消息图像总量上限,取小者。
|
|
400
|
+
const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes);
|
|
401
|
+
if (info.size > byteCap) {
|
|
402
|
+
records.push({ path, error: `image is ${info.size} bytes (cap ${byteCap}); downscale or clip it` });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (signal.aborted)
|
|
406
|
+
break; // 用户已取消:别再做读盘/存附件的重活
|
|
407
|
+
const data = await readFile(path);
|
|
408
|
+
const ref = await attachments.saveImage({ data: new Uint8Array(data), mediaType });
|
|
409
|
+
records.push({ path, attachment: ref });
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
records.push({ path, error: `failed to read screenshot: ${String(error?.message ?? error)}` });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return records;
|
|
416
|
+
}
|
|
417
|
+
/*
|
|
418
|
+
* 结果渲染:结构化 JSON → 进入模型上下文的内容块数组。
|
|
419
|
+
* - 文本块:除 screenshots 外的字段 pretty-print 成 JSON(超 30k 截断);
|
|
420
|
+
* - 图像块:每张成功转附件的截图追加一个 image 块——模型由此"看见"截图。
|
|
421
|
+
*/
|
|
422
|
+
function renderValue(value) {
|
|
423
|
+
const { screenshots, ...textual } = value;
|
|
424
|
+
let text;
|
|
425
|
+
try {
|
|
426
|
+
text = JSON.stringify(stripAttachmentNoise(textual, screenshots), null, 2);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
text = String(textual);
|
|
430
|
+
}
|
|
431
|
+
if (text.length > MAX_RENDERED_CHARS) {
|
|
432
|
+
text = `${text.slice(0, MAX_RENDERED_CHARS)}\n… (rendered output truncated)`;
|
|
433
|
+
}
|
|
434
|
+
const blocks = [{ type: 'text', text }];
|
|
435
|
+
for (const shot of screenshots ?? []) {
|
|
436
|
+
if (shot.attachment !== undefined) {
|
|
437
|
+
blocks.push({ type: 'image', attachment: shot.attachment });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return blocks;
|
|
441
|
+
}
|
|
442
|
+
/*
|
|
443
|
+
* 文本块里的截图信息瘦身:保留"哪张成了/哪张为什么失败"(路径+错误),
|
|
444
|
+
* 但不把附件引用对象整个 dump 进文本(那是一坨无意义的内部结构噪音)。
|
|
445
|
+
*/
|
|
446
|
+
function stripAttachmentNoise(textual, screenshots) {
|
|
447
|
+
if (screenshots === undefined)
|
|
448
|
+
return textual;
|
|
449
|
+
return {
|
|
450
|
+
...textual,
|
|
451
|
+
screenshots: screenshots.map((shot) => shot.error !== undefined ? { path: shot.path, error: shot.error } : { path: shot.path, attached: true }),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/*
|
|
455
|
+
* 按错误类别给 CLI 错误补一句"接下来该怎么办"的提示(给模型看):
|
|
456
|
+
* 没装 → 说明未安装;实例歧义 → 指路 /tabbit-info 和 settings;暂不可用/忙 →
|
|
457
|
+
* 建议稍后重试;超时 → 提醒先核实页面状态再重试带副作用的操作。
|
|
458
|
+
*/
|
|
459
|
+
function friendlyCliError(error) {
|
|
460
|
+
switch (error.kind) {
|
|
461
|
+
case 'launcher-missing':
|
|
462
|
+
return `${error.message} (Tabbit Browser integration is not installed on this machine.)`;
|
|
463
|
+
case 'instance-selection':
|
|
464
|
+
return `${error.message} (Run /tabbit-info to list instances, then set settings key tabbit.instance.)`;
|
|
465
|
+
case 'browser-unavailable':
|
|
466
|
+
return `${error.message} (Tabbit Browser may be starting; retry shortly.)`;
|
|
467
|
+
case 'busy':
|
|
468
|
+
return `${error.message} (The browser runtime is at capacity; retry shortly.)`;
|
|
469
|
+
case 'tab-claim':
|
|
470
|
+
return error.message;
|
|
471
|
+
case 'timeout':
|
|
472
|
+
return `${error.message} (After a timeout, verify page state before retrying side-effectful actions.)`;
|
|
473
|
+
default:
|
|
474
|
+
return error.message;
|
|
475
|
+
}
|
|
476
|
+
}
|