欢迎光临
我们一直在努力

从 DeepSeek Harness 开始,定制一个属于自己的 Linux AI Agent(Day9:实现第一个 Linux 专用工具)

Day4、Day5 分析了 agent loop,Day6 到 Day8 依次梳理了工具调用、命令执行、权限与沙箱。这一篇沿用已有机制,接入 Linux 系统信息工具 linux_system_info。

日常使用时,在 Agent 的聊天界面里直接输入:

看看你当前运行环境是什么系统,CPU 负载怎么样,内存和磁盘还剩多少空间。

用户不需要知道工具名。模型根据请求和可见工具的描述选择工具;如果选择了 linux_system_info,就发起一次参数为 {} 的调用。Harness 执行采集函数,把结果交回模型,模型再根据结果回答。

用户不用先运行脚本,再把输出粘贴给模型。后面的接入测试会指定工具名,以便检查实际调用记录;日常使用没有这个要求。

1. 工具定位

查系统信息,用 uname、free、df 就能完成。Day7 已经分析过 Bash 工具,所以这里要解决的,是把一个反复使用的检查动作,做成 Agent 能直接选择、输出字段固定的能力。

原来的 Harness 已经能完成同样的查询:用户用自然语言提出请求,模型调用 Bash 查询系统,再解释结果。根据自然语言选择已注册工具的机制,本来就有。

从当前工程的原有工具看,没有一个与这次采集范围、返回结构一致的 Linux 系统信息专用工具。但从用户最终能查到什么来看,功能重叠很大。这一篇新增的是专用工具入口和固定数据结构,不是此前做不到的系统查询能力。

通过 Bash 检查时,模型需要先组织命令,再理解终端输出。专用工具则提供一个明确入口,返回值里区分内存总量、可用量、空闲量,以及文件系统的已用和可用空间。后面需要展示数据,也不用从自然语言回答里重新提取数值。

如果只是偶尔问一次系统版本,用已有 Bash 就足够。这里选择这个小功能,是为了实际走一遍自定义工具的接入过程,同时把后续可能反复使用的数据口径固定下来。

本次采集范围限于只读系统信息,不涉及服务管理或 SSH 远程执行。

2. Agent 调用链路

采集函数通过插件注册进入工具服务,再由 agent loop 调度。完整链路如下:

standard preset 加载插件
↓
插件向 tools 服务注册 linux_system_info
↓
Harness 把工具名称、描述、参数结构提供给模型
↓
用户在聊天界面提出检查请求
↓
模型返回 tool call:linux_system_info,参数 {}
↓
agent loop 调度工具运行时,执行注册的 execute()
↓
collectSystemInfo() 读取系统信息
↓
工具结果写回会话,进入下一步模型请求
↓
模型根据结果回答用户

模型不会直接执行 TypeScript,也不知道采集函数放在哪个文件里。它知道的是工具的名字、用途和参数,真正执行代码的是 Harness。

调用 ctx.tools.register() 后,工具服务按当前 agent 的作用域生成可见工具列表。在原生工具模式下,wireSchemas() 将工具的名称、描述和参数结构提供给模型请求。

因此,description 会帮助模型判断什么时候调用工具。这里要写清楚:读取本地 Linux 环境,不执行 Shell;负载不是 CPU 使用率;部分字段可能缺失。代码执行正确,还需要模型正确理解数据。

本文使用 standard preset 的原生工具调用方式。如果切换到只向模型暴露 run_code 的 PTC 模式,模型看到的入口会不同,不能照着下面的直接调用名称判断工具是否加载。

Harness 内部的工具调用块如下,这里仅展示结构:

{
"type": "tool-call",
"id": "call_example",
"name": "linux_system_info",
"arguments": "{}"
}

不同模型供应商的原始协议可能不同,适配层会转换成框架内部的消息结构。

agent.ts 检查模型消息中的 tool-call,交给 executeToolCalls()。调度器解析参数,把当前 agent 和取消信号一起传给工具运行时,再调用我们注册的 execute()。

采集函数返回对象,工具运行时处理返回值,调度器再把结果写入会话,关联这次调用。

这一版通过 output.render 把对象渲染成 JSON 文本。下一步模型请求带上工具结果,模型才能根据这些数据回答。

用户问“内存是不是不够”,工具负责给出内存数据,模型负责解释。插件自身不请求模型,也不直接生成聊天回复。这和 Day6 分析的是同一条工具执行路径,不需要修改 agent loop。

3. 实现与接入

3.1 系统信息采集

新增包放在 packages/linux/tool-system-info,主要分成三个文件:collect.ts 负责采集,schema.ts 定义返回值结构,index.ts 负责插件注册。完整实现放在工程里,正文只贴关键部分。

分组内容读取方式
system 内核版本、架构、运行时间 node:os
distribution 发行版 ID、显示名称 /etc/os-release
cpu 型号、逻辑 CPU 数、可用并行度、平均负载 node:os
memory 总内存、可用内存、空闲内存、Swap /proc/meminfo
filesystem 工作目录所在文件系统的容量 realpath()、statfs()

这里直接使用 Node API,没有拼接 Shell 命令。

工具读取的是 Harness 进程所在的 Linux 运行环境。 Harness 部署在哪,工具就在哪里执行;聊天里提到远程服务器的 IP,不会自动改变目标。

如果跑在容器里,读取的是容器可见的系统接口。这一版没有解析 cgroup 的内存限制和 CPU quota,不能把这些数值直接当成容器配额。

文件系统这一项是工作目录所在文件系统的容量,不是项目目录的文件大小总和。

os.loadavg() 返回 1、5、15 分钟平均负载,不是百分比。这一版没有做两次 CPU 时间采样,所以没有输出 CPU 使用率。

logicalCount 来自 os.cpus(),availableParallelism 来自运行时给出的并行度估计。两者含义不同,也不能替代完整的容器资源限制分析。

MemFree 是空闲内存,MemAvailable 是内核对可供新程序使用内存的估计。Linux 会使用内存缓存数据,只看空闲内存,很容易误判。

因此两者都返回。/proc/meminfo 中这些字段的 kB 按 1024 字节换算;没有 Swap 时,返回 0 是正常情况。如果没有 MemAvailable,就返回 null,不自行拼一个估算值。

statfs() 的 bfree 是空闲块数,bavail 是普通用户可用块数。存在保留块时,两者可能不同。

计算关系如下:

总字节数 = blocks × bsize
已用字节数 = (blocks – bfree) × bsize
可用字节数 = bavail × bsize
使用率 = (blocks – bfree) / (blocks – bfree + bavail) × 100

磁盘计数用 bigint 读取,字节数以十进制字符串返回,避免大整数转成 number 时丢失精度。使用率保留两位小数,核对 df 时要考虑整数显示的取整差异。

某个分组失败时,该分组返回 null,原因放进 issues,整体状态变成 partial。这样发行版信息读不到时,已经采集到的内存和 CPU 信息仍然可以使用。

status: "ok" 表示没有分组采集错误,不表示所有可选字段都有值。例如缺少 MemAvailable 时,availableBytes 仍然可能是 null。

发行版文件只在 /etc/os-release 不存在时回退到 /usr/lib/os-release,权限错误不会被回退吞掉。系统文件按数据解析,不通过 source 执行。工作目录不存在时,也不会悄悄改查根目录。

执行上下文里的 signal 传给采集函数,readFile() 使用这个信号,其他步骤在读取前后检查取消状态。取消不能被记成普通分组错误,再返回一份 partial 数据。

realpath() 和 statfs() 无法通过同样的信号直接打断,所以仍然是协作式取消。注册的超时也不能理解成能强制终止任何底层 I/O。

3.2 工具注册

collect.ts 实现采集逻辑,index.ts 将它注册到工具服务。下面省略了 import,缩短了描述,其余关键逻辑与实现一致:

export const name = 'tool-linux-system-info'
export const inject = ['tools']

export function apply(ctx: Context): void {
if (process.platform !== 'linux') return
ctx.tools.register(defineTool({
name: 'linux_system_info',
description: 'Read local Linux system, CPU load, memory and workspace filesystem capacity.',
parameters: {},
isConcurrencySafe: () => true,
timeoutMs: 10_000,
output: {
schema: SYSTEM_INFO_SCHEMA,
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
},
async execute(args, exec) {
if (Object.keys(args).length !== 0) {
throw new Error('linux_system_info accepts no arguments; pass {}')
}
const workspace = exec.agent?.session.header.cwd
if (workspace === undefined) {
throw new Error('linux_system_info requires an agent session with cwd')
}
return collectSystemInfo(workspace, exec.signal)
},
}))
}

ctx.tools.register() 把工具交给已有的工具服务。只导出采集函数,模型不会自动发现它。

工作目录来自当前会话,模型不用自己提供,也不能通过额外参数指定任意系统文件。框架的参数根对象允许额外字段,因此执行函数还要显式拒绝非空参数。

output.schema 约束返回值,output.render 定义模型收到的文本。schema.ts 使用 Harness 自己的 schema 声明格式,允许失败分组为 null,并固定对象字段。

这是只读工具,所以声明可以并发执行。它没有进入 Bash 执行器,Day8 讲的 Bash 沙箱也不会自动包住这段 Node 代码,读取权限仍受 Harness 进程的系统用户权限约束。

3.3 插件加载

写好 apply(),还要让当前 agent 的配置加载插件。

新增包已接入根目录的 workspace 依赖、TypeScript 路径映射和 host 项目引用。然后在下面这份配置中加入插件:

packages/preset/agent-presets/presets/standard/agent.cordis.yml

– id: tool–linux–system–info
name: '@deepseek-ai/dsh-tool-linux-system-info'
disabled: !!js process.platform !== 'linux'

当前 Web 入口通过 agent presets 加载 agent 的工具。standard 加载插件后,使用它的会话才能在对应作用域中看到注册的工具,因此测试会话需要选择 standard。

在源码工程根目录更新依赖、重新构建:

corepack pnpm install
corepack pnpm run build

然后按自己的部署方式重启 Harness,打开 Web 界面,新建会话。这次接入针对当前源码工程,不是给现成发布版加一段配置就能自动安装这个新包。

4. 调用验证

第一次验证接入,可以先明确指定工具:

调用 linux_system_info,检查你当前运行环境的系统版本、CPU 负载、内存和工作目录所在文件系统的容量,再解释一下结果。

这样便于检查新工具的注册和执行链路,不代表只有写出接口名才能使用。

检查调用记录,应该确认三件事:

  • 调用名是 linux_system_info,参数是 {}。
  • 对应结果包含 executionScope、system、cpu、memory、filesystem 和 issues 等字段。
  • 最终回答使用返回的数据,有缺失字段时说明缺失,没有自行补出数值。
  • 之后再试自然一点的请求:

    看看你当前运行环境的内存和磁盘还有多少空间。

    这一步观察模型能否根据描述主动选择工具。工程里同时存在 Bash,模型也可能选择 Bash;注册工具只是提供一个可选能力,不会强制每个相关问题都走这个入口。

    如果希望日常查询优先走专用工具,可以在 agent 的指令中加入:

    查询当前 Linux 运行环境的系统版本、CPU 负载、内存或文件系统容量时,优先使用 linux_system_info;需要进一步排查时再使用其他工具。

    这是一条可选的工具选择指令,当前实现没有自动添加它。模型会结合指令、工具描述和上下文判断,不是代码按关键词硬性分流,也不能保证每次都选择同一个工具。

    如果记录是 Bash 执行了脚本,那次测试验证的是 Bash 路径,不能算这个工具的直接调用成功。如果只有自然语言回答,没有对应工具结果,也不能据此判断接入完成。

    如果提示工具不存在,按顺序检查当前 preset、Linux 运行环境、包是否安装构建、服务是否加载新代码,以及工具是否受当前作用域或工具模式限制。

    工程中保留了 smoke.ts,直接调用同一个采集函数:

    node –import tsx packages/linux/tool-system-info/smoke.ts "$PWD"

    这个脚本用于排查采集逻辑。正式对话走“模型 tool call → 工具运行时 → execute → 采集函数”,独立脚本只走最后的采集部分。

    保留它,是为了区分两类原因:独立采集就失败,先查系统读取;独立采集正常但 Agent 看不到工具,再查加载和作用域。

    可以用 uname -r、cat /proc/loadavg、free -b、df -B1 对照字段和单位。动态指标有采样时间差,架构也可能存在 x64 与 x86_64 这样的命名差异,不要求文本逐字相同。

    再传入不存在的目录,预期文件系统分组为 null、状态为 partial,issues 有错误,脚本退出码为 2。正常完整采集退出码为 0。

    工程中的检查命令是:

    corepack pnpm exec tsc -b packages/linux/tool-system-info/tsconfig.json
    node –import tsx –test packages/linux/tool-system-info/collect.test.ts packages/linux/tool-system-info/tool.test.ts

    测试覆盖单位转换、保留块、大整数、路径回退、部分失败、取消,以及注册和 schema 转换。它们与 Web 中实际调用检查的是不同层次,不能互相替代。

    赞(0)
    未经允许不得转载:171主机测评 » 从 DeepSeek Harness 开始,定制一个属于自己的 Linux AI Agent(Day9:实现第一个 Linux 专用工具)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址