# 联网工具与 Code Mode

> webfetch 的内容协商、大小与超时限制和 HTML 转 Markdown，websearch 的启用条件与 Exa、Parallel 分流，以及让模型写程序编排 MCP 工具的 Code Mode。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.net/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.net/manual/opencode/web-and-codemode
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

## webfetch：抓取网页

参数有 `url`、`format`（markdown、text、html，默认 markdown）和 `timeout`（秒）。三个常量定下边界：

```ts
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
```

（`packages/opencode/src/tool/webfetch.ts:9`）

流程如下：

1. 只接受 `http://` 与 `https://` 开头的 URL，然后以 URL 为模式询问 `webfetch` 权限；
2. 按目标格式构造带 q 值的 `Accept` 头，比如要 markdown 时依次偏好 `text/markdown`、纯文本、HTML，User-Agent 则伪装成桌面 Chrome；
3. 响应头声明的 `content-length` 与实际字节数都不得超过 5 MB；
4. 图片（SVG 除外）作为附件返回；HTML 在 markdown 模式下用 Turndown 转成 Markdown（ATX 标题、围栏代码块，去掉 script、style、meta、link），text 模式用 htmlparser2 抽取文本并跳过 script、style、iframe 等标签。

还有一处针对 Cloudflare 的处理（删减后）：

```ts
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
const response = yield* httpOk.execute(request).pipe(
  Effect.catchIf(
    (err) =>
      err.reason._tag === "StatusCodeError" &&
      err.reason.response.status === 403 &&
      err.reason.response.headers["cf-mitigated"] === "challenge",
    () =>
      httpOk.execute(
        HttpClientRequest.get(params.url).pipe(
          HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
        ),
      ),
  ),
  // ...
)
```

（`packages/opencode/src/tool/webfetch.ts:78`）

请求头自称 Chrome，TLS 指纹却对不上，反而会被 Cloudflare 判为机器人；遇到 challenge 时就换回诚实的 `opencode` UA 再试一次。

webfetch 没有自己的截断逻辑，长页面交给[通用截断](https://daiw.net/manual/opencode/tool-contract)：超过 2000 行或 50 KB 就落盘并返回预览。另外，webfetch.txt 里“HTTP 会自动升级为 HTTPS”“内容过大时可能被摘要”两句，在当前代码里都找不到对应实现。

## websearch：两家搜索服务

websearch 并非总是可用，注册表每轮先判断：

```ts
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
  return (
    providerID === ProviderV2.ID.opencode ||
    providerID === ProviderV2.ID.make("opencode-go") ||
    flags.exa ||
    flags.parallel
  )
}
```

（`packages/opencode/src/tool/registry.ts:58`）

也就是：当前模型来自 opencode 自家的提供方（opencode、opencode-go），或者打开了 `OPENCODE_ENABLE_EXA`（总开关 `OPENCODE_EXPERIMENTAL` 与旧名 `OPENCODE_EXPERIMENTAL_EXA` 同样生效）、`OPENCODE_ENABLE_PARALLEL` 之一。

后端有 Exa 与 Parallel 两家，选择顺序是：环境变量 `OPENCODE_WEBSEARCH_PROVIDER` 指定的优先；否则开了 Parallel 开关用 Parallel，开了 Exa 开关用 Exa；都没开就按会话 ID 校验和的奇偶分流，同一会话始终落在同一家。

调用方式也有意思。两家都提供远程 MCP 端点（`https://mcp.exa.ai/mcp`、`https://search.parallel.ai/mcp`），但 `mcp-websearch.ts` 没有引入 MCP 客户端，而是手工 POST 一个 JSON-RPC 的 `tools/call` 请求；响应可能是 JSON 也可能是 SSE，后者逐行找 `data:` 前缀解析；超时 25 秒。`EXA_API_KEY` 与 `PARALLEL_API_KEY` 都是可选的。

参数上，Exa 接收 `query`、`numResults`（默认 8）、`livecrawl`、`type`（auto、fast、deep）与 `contextMaxCharacters`；Parallel 接收 `objective`、`search_queries`，外加会话 ID 与模型名。说明文字里的 `{{year}}` 每次读取时替换成当年年份，提醒模型搜“最新”信息时带对年份。

## Code Mode：让模型写程序调工具

### 要解决的问题

MCP 服务器一多，工具定义本身就吃掉大量上下文；每个有依赖关系的调用都要模型往返一次；中间结果也全部流经上下文。`packages/codemode/codemode.md` 列出的目标正对着这几点：压缩大工具目录占用的上下文，免去依赖调用之间的往返，把大块中间结果留在程序里，并且只给生成的代码宿主明确授予的权限。

### 它是什么

`packages/codemode` 是一个受限的 JavaScript 解释器：先擦掉 TypeScript 语法，用 Acorn 解析，再由自有的树遍历解释器执行，不使用 `eval`。程序只能访问宿主提供的 `tools` 树（`packages/codemode/README.md:58` 的示例）：

```ts
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
```

它支持的是为编排裁剪的子集：变量与解构、条件和循环、箭头函数、`try`/`catch`、`Promise.all` 等，没有模块、类、生成器、定时器和任意宿主全局对象。工具调用最多 8 个并发。工具太多时说明里只内联一部分签名（默认约 2000 个估算 token 的预算，按命名空间轮流分配），其余靠内置的 `tools.$codemode.search` 检索。

### 开启之后

设置 `OPENCODE_EXPERIMENTAL_CODE_MODE=true`（或总开关 `OPENCODE_EXPERIMENTAL`）后，V1 会发生三件事：

1. 注册表动态导入 `tool/code-mode.ts`，多出一个 `execute` 工具，说明由一句简介加 `describeCatalog` 生成的 Code Mode 指南与 MCP 工具目录组成；没有可见的 MCP 工具时它不出现；
2. `SessionTools.resolve` 在添加 MCP 工具之前提前返回，MCP 工具不再逐个暴露给模型；
3. MCP 工具按服务器分组成命名空间，服务器名按最长前缀匹配，于是键 `github_list_issues` 变成 `tools.github.list_issues`。

### 子调用照样过权限

`execute` 里的每次子调用都会触发插件钩子并询问权限（删减后）：

```ts
const result: CallToolResult = yield* Effect.gen(function* () {
  yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
  // Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
  return yield* Effect.promise(async () => {
    const raw = await input.entry.tool.client.callTool(
      { name: input.entry.tool.def.name, arguments: input.args },
      CallToolResultSchema,
      // ...
    )
    // ...
```

（`packages/opencode/src/tool/code-mode.ts:146`）

权限名就是 MCP 工具的键，与直接暴露时一致，所以针对 MCP 工具的授权规则在 Code Mode 下照样生效；目录本身也先按 agent 与会话的合并规则集过滤（`Permission.visibleTools`）。这对应 codemode README 所说的权限边界：解释器只负责把程序关在给定的工具树里，授权始终是宿主和叶子工具的事。

子调用结果优先取 `structuredContent`，其次是文本；图片、音频和二进制资源不进解释器，由宿主收集后挂到 `execute` 的结果上。`onToolCallStart`、`onToolCallEnd` 两个钩子把每个子调用的状态写进 `metadata.toolCalls`，TUI 据此显示嵌套调用进度。V1 没有给解释器设超时、调用次数或输出上限：用户中止会打断整个执行，输出长度交给通用截断，README 也点名说 OpenCode 属于这种宿主。

V2 里 Code Mode 由 core 注册表统一接管，延迟加载的工具组被收进保留的 `execute` 工具，见 [V2 工具与权限](https://daiw.net/manual/opencode/tools-v2)。

下一篇：[权限系统](https://daiw.net/manual/opencode/permission)——规则怎么求值、“总是允许”存在哪里，以及 `--auto` 到底放开了什么。
