这是「从零搭建 Agent」系列的第 2.5 章。在完成了最小 Agent Loop 的构建之后,我们已经打通了” 感知 - 思考 - 行动”(Observe-Think-Act)的基础循环机制。

但第 2 章的系统还太简单了:只有一个 Provider,只有 `calculator` 和 `get_weather` 两个教学工具,几乎不会制造真正的上下文压力。为了让后续的 Context Manager、Memory、Planner 这些 Harness 模块有东西可管,我们需要先做一次工程上的”基础堆量”:横向扩展 LLM Provider,纵向扩展基础工具箱,让 Agent 开始接触文件、命令、网页和更复杂的错误信息。

# 为什么需要” 堆量”?

在第 1 章里,我们把 Harness 定义为上下文工程(Context Engineering)和注意力管理(Attention Management)系统。它要解决的核心矛盾是 Agent 执行过程中产生的无限信息空间,与 LLM 有限且易受干扰的上下文窗口之间的矛盾。

但在第 2 章的最小 Agent Loop 里,这个矛盾其实还没有充分暴露:

当工具里只有 calculatorget_weather 时,工具结果通常只有几行文本;当 Provider 只有 OpenAI Responses API 时,Agent Loop 也不用面对不同模型接口之间的消息格式差异。整个系统虽然能跑,但还没有真正进入复杂环境,没有做出行动的能力。

所以第 2.5 章要做的不是直接上 Context Manager,而是先把 Agent 的手脚接上:

扩展方向本章新增为什么现在做
多 ProviderOpenAI Responses、OpenAI Chat Completions / compatible、Anthropic Messages验证 Agent Loop 不应该绑定某一家 API
文件工具read_filewrite_fileappend_filelist_directory让 Agent 能读取和写入真实任务材料
Shell 工具execute_command让 Agent 能运行本地命令,并把失败也作为 observation
Web 工具web_searchfetch_url让 Agent 能搜索实时信息并抓取网页正文
截断机制head / tail truncation防止工具输出一次性塞爆上下文

换句话说,上一章让 Agent 动起来,这一章能让 Agent 接触真实世界。

当前对应的 git 提交是:

66ce14c stage1.5 add core tools and more provider

# 同类 Agent 是怎么实现多 Provider 支持和基础工具的

在写 stage1.5 之前,我依然以 Codex 和 Pi 作为工程上的指导。它们的复杂度比我们当前项目高很多,但各自提供了很清楚的工程参照。

# Codex:Provider 是配置项,工具是受治理的 runtime

Codex 把 Provider 抽象成一个  ModelProviderInfo ,里面不只是  base_url  和  env_key ,还包括:

wire_api
request_max_retries
stream_max_retries
stream_idle_timeout_ms
http_headers
env_http_headers
auth
aws
supports_websockets

在 Codex 中,Provider 层要能独立承载鉴权、重试、超时、headers、wire format,不加重 Agent Loop 的负担。

Codex 的工具系统实现更重,它的工具 runtime 不只是 name 和 execute,还具有统一的 runtime contract、调用前后的 Hook 点、权限审批流程,以及明确的工具调用事件。对我们这一章最重要的启发有三点:

  1. ** 工具错误不能直接炸掉 Loop。** 任何调用失败都应该变成模型可见的 observation,让模型下一轮继续反思修正。
  2. 工具调用要能被权限管控。 Codex 有审批和沙箱实现工具安全可控调用;这一章里还没实现完整调用审批,已经留了扩展的点。
  3. **Provider 和 Tool 都不能写死在 Loop 里。**Loop 只负责循环语义,具体 API 和具体工具 runtime 都应该在边界层处理。

# Pi:TypeScript 里的轻量 Provider Registry 和 Core Tools

Pi 的 Provider 用 registry 注册不同 Provider,再根据 model.api 找到对应实现。

简化后大概是:

const apiProviderRegistry = new Map<string, ApiProvider>();
export function registerApiProvider(provider: ApiProvider): void {
  apiProviderRegistry.set(provider.api, provider);
}
export function getApiProvider(api: Api): ApiProvider | undefined {
  return apiProviderRegistry.get(api);
}

Pi 的  read  工具有几个关键点:

  • 支持  offset  /  limit
  • 默认对文本做 line /byte 截断。
  • 描述里会提醒模型:大文件要继续用 offset 读。
  • 通过  ReadOperations  抽象读取逻辑,未来可以委托到远端系统。

Pi 的  bash  工具也不是简单  child_process.spawn  一跑了事:

  • 支持 timeout。
  • stdout/stderr 会流式累积。
  • abort 时会 kill process tree。
  • 工具输出会做 truncation。
  • 通过  BashOperations  抽象执行逻辑,便于被远端执行或扩展 hook 替换。

# 核心模块一:扩展 LLM Provider

第 2 章里我们已经把模型调用抽象成了 LlmClient

export interface LlmClient {
  complete(request: LlmRequest): Promise<AssistantMessage>;
}
export interface StreamingLlmClient extends LlmClient {
  stream(request: LlmRequest): AsyncIterable<LlmStreamEvent>;
}

这个接口现在开始发挥价值。stage1.5 新增 Provider 时,没有改 Agent Loop 的核心语义:Agent 仍然只是把 messagestoolssystemPrompt 交给 LLM Client,然后等待一个统一格式的 AssistantMessage

OpenAI Responses、Chat Completions、Anthropic Messages 对工具调用的表示方式都不一样。但 Agent Loop 不应该知道这些差异。它应该只面对统一的内部消息格式、工具调用格式和流式事件格式。

当前支持三个 Provider:

Provider ID对应接口典型用途
openai-responsesOpenAI Responses API默认路径,延续第 2 章实现
openai-chatOpenAI Chat Completions / compatibleOpenAI 兼容服务、本地 Ollama、第三方模型网关
anthropicAnthropic Messages APIClaude 系列模型

我们采用了类似 Pi 的 Provider Registry 实现:

export class LlmProviderRegistry {
  private readonly providers = new Map<string, LlmProviderFactory>();
  register(provider: LlmProviderFactory): void {
    if (this.providers.has(provider.id)) {
      throw new Error(`LLM provider already registered: ${provider.id}`);
    }
    this.providers.set(provider.id, provider);
  }
  get(id: string): LlmProviderFactory {
    const provider = this.providers.get(id);
    if (!provider) {
      throw new Error(`No LLM provider registered for: ${id}`);
    }
    return provider;
  }
}

基本上能实现通过 provider id 找到对应 LLM Client factory 的任务。


# 核心模块二:丰富基础工具箱

第 2 章的工具系统已经支持注册、schema 暴露、参数校验、并行 / 串行执行、错误回填。stage1.5 在它上面新增了一组更接近真实 Agent 的 core tools。

Core tools 由  createCoreTools()  创建:

export type CoreToolset = "basic" | "files" | "shell" | "web" | "all";
export function createCoreTools(options: CoreToolsOptions = {}): AgentTool[] {
  const rootDir = options.rootDir ?? process.cwd();
  const toolset = options.toolset ?? "basic";
  const tools: AgentTool[] = [calculatorTool, mockWeatherTool];
  if (toolset === "files" || toolset === "all") {
    tools.push(...createFileSystemTools({ rootDir, ...options.fileSystem }));
  }
  if (toolset === "shell" || toolset === "all") {
    tools.push(createShellTool({ rootDir, ...options.shell }));
  }
  if (toolset === "web" || toolset === "all") {
    tools.push(...createWebTools(options.web));
  }
  return tools;
}

可以通过命令行环境变量显示选择暴露给 Agent 的工具。文件、命令和网络都会改变 Agent 的能力边界,所以需要在运行入口显式选择。

# 文件系统工具

文件工具是 Agent 获得持久化能力和长文本感知能力的基础。

工具名称核心功能关键设计点
read_file读取 UTF-8 文件内容支持 offset / limit ,对超长输出做 head 截断,并提示如何继续读取
write_file写入并覆盖文件自动创建父目录,返回写入字节数
append_file追加文件内容自动创建父目录和文件,适合渐进式生成
list_directory列出目录内容返回结构化 JSON,包含名称、类型、大小、是否达到 entry limit

文件工具最重要的保护是路径边界。 src/tools/helpers.ts 里的 resolveWithinRoot() 会确保路径不能逃出 rootDir

const root = resolve(rootDir);
const target = resolve(root, path || ".");
const rel = relative(root, target);
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
  return target;
}
throw new Error(`Path escapes rootDir:${path}`);

这意味着即使模型尝试路径穿越读取外部文件:

{ "path": "../../.ssh/id_rsa" }

工具也会直接返回错误,而不是越过工作目录边界。(这个实现只是简单的防护)

read_fileoffsetlimit 是为后续 Context Manager 提前留下的接口:

{
  "path": "logs.txt",
  "offset": 200,
  "limit": 80
}

大文件不应该一次性塞进上下文。更合理的方式是分段读取、摘要、再决定要不要继续读。

# 系统执行工具

execute_command 是本章里最强也最危险的工具。

它赋予 Agent 执行 shell 命令的能力,

这个工具重点处理四件事:

机制作用
timeoutMs防止命令无限阻塞 Agent Loop,默认 30 秒
stdout /stderr 捕获让模型看到命令正常输出和错误输出
exit code非零退出码标记为 isError: true
tail truncation命令失败时保留末尾更有价值的错误信息

命令调用非零退出码、超时、abort 都会标记为  isError: true

return {
  content: content.content,
  isError: result.aborted || result.timedOut || result.exitCode !== 0,
  details: {
    command,
    workdir,
    ...result,
    truncation: content.details
  }
};

结果会被包装成这样的 observation:

Exit code: 3
Timed out: false
Aborted: false
Wall time: 0.1 seconds
[stdout]
(empty)
[stderr]
bad

这里延续了第 2 章的原则:失败也是一等公民(Failures as First-Class Citizens)

工具失败时,Agent Loop 不应该直接崩掉。模型需要看到失败原因,然后在下一轮决定要不要改命令、读文件、换路径,或者向用户报告。

# 网络与搜索工具

网络工具被拆成两个小工具,用于模型没有原生网络访问能力的情况:

工具名称核心功能关键设计点
web_search通过 Tavily 搜索实时信息返回标题、URL、摘要、分数;缺少 TAVILY_API_KEY 时返回模型可见错误
fetch_url获取 HTTP (S) URL 文本内容HTML 用 cheerio 去掉脚本和样式,只返回正文,并做 head 截断

web_search 负责帮模型找到候选信息源; fetch_url 负责读取某个具体页面。搜索结果和网页正文的粒度不同,如果混成一个工具,输出会很容易不可控。


# 截断:第一个迷你 Context Manager

这是一个很小的上下文保护层,主要学习了 Pi 中读取文件时保护上下文容量的机制。

默认限制是:

export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024;

它提供两种方向:

截断方式用在哪里为什么
truncateHead文件读取、目录列表、网页正文先看开头,适合正文和结构化列表
truncateTailshell 命令输出错误日志通常越靠后越有价值

截断时会给模型继续行动的提示,告诉模型下一步可以怎么继续行动:

[Truncated: showing lines 2-4 of 5; 13B/23B selected bytes. Use offset=4 to continue.]

# 组合不同” 大脑” 和工具集

# 创建 Agent:Provider 和 Toolset 都来自环境

import { Agent, createCoreTools, createLlmClientFromEnv, type CoreToolset } from "../src/index.js";
const llm = createLlmClientFromEnv();
const toolset = (process.env.AGENT_TOOLSET ?? "basic") as CoreToolset;
const agent = new Agent({
  llm: llm.llm,
  model: llm.model,
  systemPrompt: "You are a concise assistant. Use tools when useful, then answer the user.",
  tools: createCoreTools({ rootDir: process.cwd(), toolset })
});

Agent 启动时,会打印当前模型调用的参数

[provider] 
[toolset] 
[maxTurns]

如果要构造一个长链路任务,可以打开全部工具:

LLM_API_KEY=your_key_here \
TAVILY_API_KEY=your_tavily_key_here \
AGENT_TOOLSET=all \
npm run demo -- "Read README.md, search for TypeScript tool-call design notes, fetch one result, and write a short summary to tmp/provider-tool-notes.md."

这类任务会让 Agent 连续经历:

read_file
  -> web_search
  -> fetch_url
  -> write_file
  -> final answer

也就是从” 能调用一个工具” 变成” 能完成一条多工具任务链”。在这样的长任务中,上下文压力会逐渐扩大,文件内容、搜索结果、网页正文、写入确认、可能的失败信息,都会进入历史。也就是说,下一章 Context Manager 的必要性得到了体现。


# 总结与展望

完成本章之后,Agent 的” 手” 更长了,“脑” 也更灵活了。

它不再只依赖 OpenAI Responses API,可以切换到 OpenAI-compatible 或 Anthropic;它也不再只会调用玩具工具,可以读取文件、写文件、执行命令、搜索网页、抓取正文。

但这同时带来一个新的问题:一个复杂任务的执行,会产生远比以前更多的上下文信息。

文件内容可能很长,命令输出可能很乱,网页正文可能夹杂噪声,工具失败可能连续发生。模型每一轮到底应该看到什么?哪些信息应该保留?哪些应该截断?哪些应该摘要?哪些状态应该固定在 prompt 前面?

这些问题,就是第 3 章上下文管理器(Context Manager)的起点。

从这里开始,Harness 不再只是理论里的” 挽具”,而是一个必须上场的工程模块。