Skip to content

开发 dsh-plugin-deepseek-browser-use:会话、作业、文件与图片 ​

上一篇已经把工具请求送到了 Java 服务。真实使用还有几个问题:两个会话会不会操作同一个任务?同一会话同时发两个命令怎么办?HTTP 已返回,但异步批次还没结束怎么办?本篇结合插件 0.2.0 的实际实现,逐步补齐这些核心能力。

1. 为什么不能只保存一个全局 taskId ​

假设 A 会话打开订单页面,B 会话打开搜索页面。如果两个会话共用一个任务,A 下一次点击可能落在 B 的页面上。把 task ID 交给模型填写同样不合适:它既可能填错,也可能引用另一个会话的任务。

当前实现用 Map<Owner, Entry> 保存状态,Owner 是通过工具入口验证过的活跃 Agent 对象。以下是 src/sessions.ts 完整文件;先通读整体,再按后续小节理解各个分支:

typescript
import { randomInt } from 'node:crypto';
import { BrowserClient, requireSuccess, type Envelope, type Params } from './client.js';

export interface Owner { ctx: { effect(callback: () => () => Promise<void>, label?: string): () => Promise<void> }; }
interface Entry { id: number; tail: Promise<unknown>; started: boolean; closed: boolean; abort: AbortController; cleanup: () => Promise<void>; jobs: Set<string>; activeJob?: string; batchUnknown?: boolean; }
export interface SessionOptions { browser: string; headless: boolean; }

/** Live owner identity, not a model-supplied task ID, controls resource access. */
export class BrowserSessions {
  private entries = new Map<Owner, Entry>();
  private disposed = false;
  constructor(readonly client: BrowserClient, readonly options: SessionOptions) {}

  private entry(owner: Owner): Entry {
    if (this.disposed) throw new Error('Browser plugin is disposed');
    const previous = this.entries.get(owner);
    if (previous) return previous;
    // 48 random bits: exact JSON/JavaScript integer, unlike server Snowflake IDs.
    const entry: Entry = { id: randomInt(1, 2 ** 48 - 1), tail: Promise.resolve(), started: false, closed: false,
      abort: new AbortController(), cleanup: async () => {}, jobs: new Set() };
    this.entries.set(owner, entry);
    entry.cleanup = owner.ctx.effect(() => async () => {
      entry.closed = true;
      entry.abort.abort(new Error('Browser session disposed'));
      await entry.tail.catch(() => {});
      if (entry.batchUnknown) throw new Error(`Task ${entry.id}: batch submission outcome unknown. Inspect service list_jobs and finish/cancel the batch before manually closing this task. Cleanup did not close potentially active work.`);
      if (entry.started) {
        const signal = AbortSignal.timeout(this.client.options.timeoutMs);
        if (entry.activeJob) {
          requireSuccess(await this.client.command(entry.id, 'cancel_job', { jobId: entry.activeJob }, signal));
          for (;;) {
            const status = requireSuccess(await this.client.command(entry.id, 'get_job', { jobId: entry.activeJob, includeResult: false }, signal));
            if ((status.data as Params)?.status !== 'running') break;
            await new Promise(resolve => setTimeout(resolve, 100));
            signal.throwIfAborted();
          }
        }
        requireSuccess(await this.client.command(entry.id, 'close', {}, signal));
      }
      this.entries.delete(owner);
    }, 'deepseek-browser-use.session');
    return entry;
  }

  run(owner: Owner, method: string, params: Params, signal: AbortSignal): Promise<Envelope> {
    signal.throwIfAborted();
    const entry = this.entry(owner);
    const combined = AbortSignal.any([signal, entry.abort.signal]);
    const operation = entry.tail.then(async (): Promise<Envelope> => {
      combined.throwIfAborted();
      if (entry.closed) throw new Error('Browser session is closed');
      if (entry.batchUnknown) throw new Error(`Task ${entry.id}: batch submission outcome unknown; inspect service list_jobs before manually recovering. This Session will not submit more browser actions.`);
      if (method === 'get_job' || method === 'cancel_job') {
        if (typeof params.jobId !== 'string' || !entry.jobs.has(params.jobId)) throw new Error('Job does not belong to this Session');
      } else if (entry.activeJob) {
        throw new Error(`Browser batch ${entry.activeJob} is pending. Use dsb_job until it finishes before issuing other commands.`);
      }
      if (method === 'close' && !entry.started) return { ok: true, data: { id: entry.id, closed: true } };
      if (!entry.started) {
        // Remember ownership before HTTP, so timeout during start is still cleaned up.
        entry.started = true;
        const started = await this.client.command(entry.id, 'start', { ...this.options }, combined);
        if (!started.ok) entry.started = false;
        requireSuccess(started);
        if (method === 'start') return started;
      } else if (method === 'start') {
        return { ok: true, data: { id: entry.id, reused: true } };
      }
      let result: Envelope;
      try { result = await this.client.command(entry.id, method, params, combined); }
      catch (error) {
        if (method === 'commands') entry.batchUnknown = true;
        throw error;
      }
      const data = result.data as Params | undefined;
      if (method === 'commands' && result.ok) {
        if (typeof data?.jobId !== 'string') {
          entry.batchUnknown = true;
          throw new Error(`Task ${entry.id}: async batch response missing jobId; inspect service before resubmitting`);
        }
        entry.jobs.add(data.jobId);
        entry.activeJob = data.jobId;
      }
      if (method === 'get_job' && result.ok && data && ['done', 'failed', 'cancelled'].includes(String(data.status)) && params.jobId === entry.activeJob) {
        entry.activeJob = undefined;
      }
      if (method === 'close' && result.ok) entry.started = false;
      return result;
    });
    entry.tail = operation.catch(() => {});
    return operation;
  }

  async dispose(): Promise<void> {
    this.disposed = true;
    const results = await Promise.allSettled([...this.entries.values()].map(entry => entry.cleanup()));
    const errors = results.filter(r => r.status === 'rejected').map(r => r.reason);
    if (errors.length) throw new AggregateError(errors, 'Browser session cleanup failed');
  }
}

这份文件可以在原工程中编译;它依赖上一篇介绍的 client.ts。Owner 接口只声明会话管理需要的 ctx.effect,不把整个 Harness Agent 类型耦合到 HTTP 会话管理器里,便于独立测试。

2. 先读懂 Entry 中的字段 ​

字段含义缺少它会怎样
id发送到 Java 的任务 ID无法把后续命令关联到自己的页面
tail当前队列尾部的 Promise同会话并发动作可能乱序
started任务是否已启动或可能已启动重复 start,或漏掉需要清理的任务
closedAgent 作用域是否已关闭排队调用可能在释放之后继续操作
abort本会话的取消控制器释放作用域时无法通知等待中的请求
cleanup注册到作用域的清理函数插件卸载时无法统一释放资源
jobs本会话创建过的 job ID 集合可能查询或取消别人的作业
activeJob尚未观察到终态的作业普通命令可能插入仍运行的批次
batchUnknown批次提交结果无法确认响应丢失后可能重复提交整个批次

randomInt(1, 2 ** 48 - 1) 在 48 位范围内选择正整数,可被 JavaScript number 精确表示,也可传入 Java long。这里避免直接使用可能超过 JavaScript 安全整数范围的服务端 Snowflake ID;随机值不是绝对无碰撞保证,也不是认证凭据。

任务归属隔离不等于登录态隔离:多个任务仍可能使用同一个浏览器 profile 的 Cookie。需要不同账号互不干扰时,应使用独立服务和 profile。

3. 顺序队列为什么写成两个 Promise ​

关注 run 中这三行的关系:

typescript
const operation = entry.tail.then(async (): Promise<Envelope> => {
  // 在这里检查取消、启动任务、发送本次命令。
});
entry.tail = operation.catch(() => {});
return operation;

这是结构示意,注释位置在完整文件中有实际实现,不是可直接替换的方法。

新的 operation 接在旧 tail 后面,因此同一 Owner 的两个请求按加入队列的顺序执行。返回给调用者的是原始 operation,调用者仍能看到失败。保存给下一项的 tail 则通过 catch 恢复为可继续等待的 Promise,避免一次失败让后续所有调用都跳过执行。

排队前检查一次取消,进入队列后还要检查一次。假设第二个点击在等待第一个请求时被取消,只在函数入口检查就会漏掉这个变化,最终仍把取消的点击发给浏览器。

4. 懒启动与 close 的两种含义 ​

首次调用 dsb_state 或 dsb_navigate 时,started 为 false,管理器先发送 start,成功后再发送目标命令。后续 dsb_start 返回 reused:true,不会重复创建任务。

为什么在 start 请求之前先设置 started = true?因为 HTTP 超时不代表服务端没有创建任务。提前记下“可能已启动”,作用域清理时才能尝试关闭它;若收到明确的 ok:false,则恢复为 false。

显式调用 dsb_close 只把 started 改回 false,保留活跃 Agent 的 Entry,所以下次可以重新 start。注意:若还存在未观察到终态的批次,dsb_close 会被直接拒绝(activeJob 检查在 close 分支之前),需要先用 dsb_job 观察到终态。Agent 作用域释放时才设置 closed = true,并取消信号、等待队列和清理任务。这两个动作语义不同。

5. 通用命令为什么还需要允许列表 ​

把模型传来的任意 method 转给服务很方便,但会绕过任务生命周期和异步作业管理。当前工具层先检查准确命令名,再排除必须走专用工具或不公开的操作:

typescript
const forbidden = new Set(['start', 'close', 'shutdown', 'cleanup', 'get_config', 'list_tasks', 'list_jobs', 'get_job', 'cancel_job', 'run_recipe', 'commands']);
export function validateCommand(method: string): void {
  if (!commandNames.includes(method) || forbidden.has(method)) {
    throw new Error(`Command ${method} is not an exposed task command. Use dedicated lifecycle/job tools; service-wide commands and recipes are not exposed.`);
  }
}

commandNames 来自 src/commands.ts,是 Java CommandTable.java 的名称快照。它不是每次联网动态发现的服务能力。新增服务命令时要更新快照,并运行现有一致性测试。

这里禁止通用入口直接调用 start/close/get_job/cancel_job,是为了统一管理会话;禁止 commands/run_recipe,是为了避免嵌套动作绕过逐项验证;禁止 shutdown/cleanup 等全局操作,是为了避免影响其他会话。这些限制不是页面 JavaScript 的安全沙箱。

6. 异步批次:队列结束不等于浏览器工作结束 ​

以下是 src/tools.ts 中通用命令、批次和作业工具的实际注册代码,依赖已有 add、command 和 object:

typescript
  add('command', 'Execute an exact supported task-scoped command. Use dsb_methods and the skill for names/parameters. No lifecycle, global management, nested batches or recipes.',
    z.strictObject({ method: z.string().min(1), params: object.optional() }), (a, e) => { validateCommand(a.method); return command(e, a.method, a.params ?? {}); });
  add('batch', 'Submit sequential task commands as a background job. Poll dsb_job before issuing other actions. Do not batch indices whose validity depends on earlier page changes.', z.strictObject({
    commands: z.array(z.strictObject({ method: z.string(), params: object.optional() })).min(1).max(200),
    stopOnError: z.boolean().optional(),
  }), (a, e) => {
    for (const step of a.commands) validateCommand(step.method);
    return command(e, 'commands', { commands: a.commands.map(step => ({ [step.method]: step.params ?? {} })), async: true, stopOnError: a.stopOnError ?? true });
  });
  add('job', 'Read or request cooperative cancellation of a job created by this Session. Cancellation does not undo an already delivered action.',
    z.strictObject({ jobId: z.string().min(1), cancel: z.boolean().optional() }), (a, e) => command(e, a.cancel ? 'cancel_job' : 'get_job', { jobId: a.jobId, ...(a.cancel ? {} : { includeResult: true }) }));

模型更容易填写这种结构:

json
{
  "commands": [
    { "method": "get_title" },
    { "method": "get_url" }
  ],
  "stopOnError": true
}

服务需要的 params 则是:

json
{
  "commands": [{ "get_title": {} }, { "get_url": {} }],
  "async": true,
  "stopOnError": true
}

map(step => ({ [step.method]: step.params ?? {} })) 完成两种格式的转换;方括号表示用变量值作为属性名。批次中的每一项先经过 validateCommand,所以不能把被禁止的命令藏进数组。

服务返回 job ID 后,这次 HTTP 就结束了,但批次可能仍在执行。BrowserSessions 因此保存 activeJob,暂时拒绝其他普通动作,只允许查询或取消本会话拥有的作业。查询到 done/failed/cancelled 才放行。

还有一条同样会卡死的路径要记住:作业结果只保存在服务端内存里(只保留最近 50 个,服务重启即丢)。作业查不到时 get_job 返回 ok:false,而实现只在 ok:true 时才清除 activeJob,于是这个会话的普通浏览器动作会被一直拒绝,错误文案却还在提示继续用 dsb_job。遇到“没有这个任务”时不要反复轮询,按 batchUnknown 的方式收尾(交给运维核查服务端作业,然后新建 Harness 会话)。

观察到的情况会话处理使用者下一步
提交成功,有 job ID保存归属并锁住普通动作调用 dsb_job
查询仍在运行保留 activeJob稍后继续查询
查询到终态清除 activeJob检查内部结果,再继续操作
取消请求成功尚不把它当作执行终态继续查询确认停止
提交响应丢失或成功响应没有 job ID设置 batchUnknown由运维核查服务端作业,不能盲目重放

作业查询外层 ok:true 只说明“查到了”,执行是否成功还要看 data.status 和内部 data.ok。页面索引会随着前一步操作变化,因此不要把依赖新索引的多次点击盲目塞进同一个批次。

7. 资源释放为什么不能直接 shutdown ​

完整 entry 方法中注册的清理逻辑依次做:标记关闭、发出取消、等待本会话队列、处理未结束的作业、关闭自己的 task、删除 Map 条目。

有活跃作业时先 cancel_job,再查询到不再 running,才 close。取消是协作式的,不会撤销已点击的按钮。清理等待受 timeoutMs 限制,不能永远阻塞卸载。

batchUnknown 时直接报告清理失败,保留 task ID,避免关闭仍可能运行的作业。运维需要在 Java 服务端 list_jobs 中匹配 browserId,等待或取消作业后关闭任务,然后使用新的 Harness 会话。插件通用工具本身不暴露全局 list_jobs。

插件级 dispose() 使用 Promise.allSettled,一个会话清理失败不会阻止尝试清理其他会话,最后用 AggregateError 汇总失败。整个路径不调用共享服务的 shutdown。

8. 上传文件:传字节而不是把本机路径交给远程服务 ​

这是 src/tools.ts 中的上传工具片段:

typescript
  add('upload', 'Upload a file from the Harness host to a file input. localPath is on the Harness host; bytes are transferred over HTTP even if browser service is remote.',
    z.strictObject({ ...target, localPath: z.string().min(1) }), async (a, e) => {
      checkTarget(a);
      const info = await stat(a.localPath);
      if (!info.isFile() || info.size > options.maxUploadBytes) throw new Error('Upload must be a regular file within maxUploadBytes');
      const bytes = await readFile(a.localPath, { signal: e.signal });
      if (bytes.length > options.maxUploadBytes) throw new Error('Upload exceeds maxUploadBytes');
      const { localPath, ...locator } = a;
      return command(e, 'upload_file', { ...locator, filename: basename(localPath), contentBase64: bytes.toString('base64') });
    });

用到的 Node import 是:

typescript
import { readFile, stat } from 'node:fs/promises';
import { basename } from 'node:path';

localPath 属于 Harness 主机,Java 服务不一定在同一台机器,所以请求发送 filename/contentBase64。stat 先检查普通文件及大小,读完再检查实际字节数,捕获检查与读取之间文件变大的情况;当前实现仍是内存读取,不是严格有界的流式上传。base64 还会增加约三分之一的传输体积。

const { localPath, ...locator } = a 从参数中分离出本机路径,避免把它误当作页面定位参数传给服务。index/selector 的二选一检查继续复用基础工具逻辑。

9. 截图:图片路径和模型看到图片是两件事 ​

这是截图工具的实际代码:

typescript
  add('screenshot', 'Capture the page or a selector. Set view=true to attach image for a verified vision-capable model; otherwise return a saved screenshot URL.',
    z.strictObject({ fullPage: z.boolean().optional(), selector: z.string().optional(), view: z.boolean().optional() }), async (a, e) => {
      const attachments = ctx.get('attachments');
      if (a.view) {
        if (!attachments) throw new Error('view=true requires the Harness attachment service');
        const route = e.agent?.session.requestHeader()?.config;
        const provider = route?.provider ?? e.agent?.options.provider;
        const model = route?.model ?? e.agent?.options.model;
        const llm = ctx.get('llm');
        if (!provider || !model || !llm) throw new Error('Cannot verify the active model image capability; use view=false');
        const info = await llm.resolveModelInfo(provider, model, e.signal);
        if (!info.inputModalities?.includes('image')) throw new Error('Current model does not declare image input; use view=false and DOM text tools');
      }
      const { view, ...params } = a;
      const result = await command(e, 'screenshot', { ...params, inline: view ?? false });
      if (!result.ok || !view) return result;
      const data = result.data as Params;
      if (typeof data?.base64 !== 'string') throw new Error('Screenshot response missing base64');
      e.signal.throwIfAborted();
      const attachment = await attachments!.saveImage({ data: Buffer.from(data.base64, 'base64'), mediaType: 'image/png', name: 'browser-screenshot.png' });
      const { base64: _bytes, ...metadata } = data;
      return { ...result, data: metadata, attachment: attachment as unknown as Params };
    });

默认 view:false 只返回截图元信息、路径或 URL,减少图片传输。view:true 时流程是:

  1. 读取当前调用路由的 provider/model,缺失时使用 Agent 配置;确认宿主附件服务和 LLM 服务可用。
  2. 通过模型信息检查 inputModalities 是否含 image,不凭模型名字猜测视觉能力。
  3. 请求 screenshot(inline:true),拿到 base64 后解码为字节。
  4. 用 attachments.saveImage 保存为宿主附件,移除规范结果里的 base64。
  5. 由基础篇的 output.render 生成 image 内容块。

如果直接把 base64 放进文本,模型不会因此得到正常的图片输入,还会占用大量上下文。没有图片能力时,可以继续使用默认截图留档和 DOM 文本工具。

10. 用测试证明这些设计有效 ​

工程的 test/plugin.test.mjs 已包含以下测试,运行 npm test 可验证:

测试情形要证明的行为
同一 Owner 并发 execute_js实际 HTTP 不重叠,且只 start 一次
两个不同 Owner使用不同的安全整数 task ID
排队点击被取消没发出点击,下一条请求仍正常执行
批次运行时再点击拒绝插入动作,观察终态后恢复
另一个 Owner 查询 job拒绝访问非本会话作业
批次响应被截断冻结会话,不重发,不假装清理成功
上传中文文件传的是实际文件字节,文件名保留
截图展示检查模型能力,保存附件,不把 base64 留在结果中

这些测试大多使用假 HTTP 服务,验证的是协议和状态管理。真实浏览器、目标 Harness 的激活、Web UI 图片显示是不同层次的验证,不能互相代替。

上一篇:HTTP 客户端与基础工具。下一篇:构建并安装到 DeepSeek Harness。