agent-core

mcp
Guvenlik Denetimi
Basarisiz
Health Uyari
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Low visibility — Only 5 GitHub stars
Code Basarisiz
  • exec() — Shell command execution in features/skill.js
Permissions Gecti
  • Permissions — No dangerous permissions requested

Bu listing icin henuz AI raporu yok.

SUMMARY

轻量的 AI Agent 核心:给它模型地址和工具文件,自动循环「问模型 → 执行工具 → 再问模型」;支持 4 种协议、工具、MCP、技能、上下文压缩、提示词缓存,纯对话模型也能用工具。

README.md

# @kernel4632/agent-core

CI
Release

一个轻量的 AI Agent 核心包。给它一个 LLM 地址和一堆工具文件,它就能自动循环"问模型 → 执行工具 → 再问模型",直到任务完成。


目录


这个包是干什么的

你可以把它理解成一个"AI 大脑驱动引擎":

你的程序
  └─ Agent.create()      ← 创建一个 AI 实例
       └─ agent.send()   ← 发送任务指令
            └─ Loop      ← 自动循环
                 ├─ LLM  ← 问模型:"下一步做什么?"
                 ├─ Tool ← 执行模型要求的工具
                 └─ 把工具结果告诉模型,继续循环

模型每次回复要么"调用某个工具",要么"我做完了"。这个包负责把这个循环跑起来,你只需要写工具文件就行。


安装

这个包需要 Bun 运行时(不支持 Node.js)。

从 GitHub Release 安装,永远拿到最新版:

bun add https://github.com/kernel4632/agent-core/releases/latest/download/agent-core.tgz

想锁死某个版本(比如 0.10.3),把链接里的 latest/download 换成 download/v0.10.3:

bun add https://github.com/kernel4632/agent-core/releases/download/v0.10.3/agent-core.tgz

以后升级到最新版,再运行一次第一条命令就行。

bun add @kernel4632/agent-core 现在还不能用:这个包尚未发布到 npm,那个名字在 npm 上不存在,运行会报 404。等发到 npm 后才能改用这条短命令,代码不用改。


5 分钟快速上手

第一步:写一个工具文件

新建 tools/say-hello.js:

export default {
    name: 'say_hello',                         // 工具名,模型通过这个名字调用它
    description: '向指定的人打招呼',             // 告诉模型这个工具是干什么的
    inputSchema: {                             // 描述模型需要传哪些参数
        type: 'object',
        properties: {
            name: { type: 'string', description: '要打招呼的人的名字' },
        },
        required: ['name'],
    },
    async execute(input) {                     // 真正执行的函数,input 就是模型传来的参数
        return `你好,${input.name}!`          // 返回值会原样告诉模型
    },
}

第二步:创建 Agent 并发送指令

新建 main.js:

import Agent from '@kernel4632/agent-core'

// 扫描工具目录,得到工具描述和执行器
const tools = await Agent.tool.scan('./tools')

// 创建一个 Agent 实例
const agent = Agent.create({
    config: {
        baseURL: 'https://你的模型地址/v1',    // OpenAI 兼容接口地址
        apiKey: 'sk-你的密钥',
        model: '模型名称',
        system: '你是一个助手,请使用工具完成用户的任务。',
    },
    tools,                                    // 把扫描好的工具传进来
    callbacks: {
        onToolResult: result => {             // 工具执行完时的回调
            console.log('工具执行完了:', result.toolName, result.output)
        },
    },
})

// 发送指令,等待完成
const result = await agent.send('帮我向小明打个招呼')
console.log('Agent 结束,原因:', result.reason)   // 'no-tool' 表示模型认为任务完成

运行:

bun main.js

项目架构

@kernel4632/agent-core
│
├── index.js              ← 入口,只导出 Agent
├── agent.js              ← Agent.create() 的实现
│
├── features/             ← 功能模块(每个只做一件事)
│   ├── loop.js           ← 主循环:LLM → 工具 → LLM → ...
│   ├── tool.js           ← 工具扫描 + 工具执行(主线程这一半)
│   ├── tool-process.js   ← 工具真正跑起来的地方(子进程那一半)
│   ├── context.js        ← 把历史消息裁剪成模型上下文
│   ├── compact.js        ← 上下文太长时自动压缩总结
│   └── skill.js          ← 扫描技能目录,内置 skill 工具按需加载
│
└── utils/               ← 基础工具
    ├── llm.js            ← 底层 LLM 请求(支持多种协议)
    ├── text-tools.js     ← 文字工具协议:让纯对话模型也能调用工具
    ├── history.js        ← 创建标准格式的历史消息块
    └── retry.js          ← 失败自动重试(指数退避)

tool.js 和 tool-process.js 是同一件事的两半,所以放在一起:前者在主线程里找工具、管工具进程,
后者被前者当成文本内联、在独立的 bun 子进程里加载并执行工具。分成两个文件是平台限制,不是分层。

工具进程隔离的是生命周期,不是环境。 工具在里面拥有和 Agent 完全相同的权限:读写任意文件、执行任意命令、联网、读到父进程的全部环境变量。这是有意的——电脑任务 agent 的工具本来就得能干这些。它真正隔离的是四样:失控(死循环工具能被一刀杀掉,实测 0ms)、崩溃(工具死了 Agent 继续跑)、内存(独立堆)、Agent 状态(工具碰不到 history、config、running)。

为什么用子进程而不是 Worker 线程:Worker 被 terminate() 之后 Bun 不归还它占的约 22MB,
而且杀线程带不走它 Bun.spawn 出来的孙进程。常驻 agent 天天要杀工具进程(工具崩溃、超时、用户打断),
两笔账会一直累积。换成子进程之后实测 200 次「起→用→杀」主进程只涨 2MB(Worker 版是 4.4GB),
孙进程一起带走,主进程退出时工具进程也全部跟着死。复用时单次调用 0.08ms,比 Worker 还快。

数据流

agent.send(input)
  │
  ├─ History.user()          把用户输入变成历史块,存入 history 数组
  │
  └─ Loop.run()
       │
       ├─ Context.build()    从 history 裁剪出模型能看的上下文(自动处理 token)
       ├─ LLM.chat()         请求模型(支持流式,失败自动重试)
       │
       ├─ [有工具调用]
       │    ├─ Tool.execute() 在独立子进程里并行执行所有工具
       │    └─ 把工具结果写入 history,继续下一轮循环
       │
       └─ [没有工具调用]
            └─ 模型不调工具则返回 { reason: 'no-tool' }

自定义工具:从零到完整

最简单的工具

// tools/add.js
export default {
    name: 'add',
    description: '把两个数字加在一起',
    inputSchema: {
        type: 'object',
        properties: {
            a: { type: 'number' },
            b: { type: 'number' },
        },
        required: ['a', 'b'],
    },
    async execute(input) {
        return input.a + input.b    // 直接返回结果
    },
}

带流式输出的工具

工具可以用 yield 逐步返回内容,适合耗时较长的操作:

// tools/count.js
export default {
    name: 'count',
    description: '从 1 数到 n,逐步输出',
    inputSchema: {
        type: 'object',
        properties: { n: { type: 'number' } },
        required: ['n'],
    },
    async *execute(input) {               // 注意:async * 是异步生成器
        for (let i = 1; i <= input.n; i++) {
            yield `数到了 ${i}`           // 每次 yield 都会立刻发给上层的 onOutput 回调
            await new Promise(r => setTimeout(r, 100))
        }
    },
}

接收流式输出:

const agent = Agent.create({
    // ...
    callbacks: {
        onToolOutput: output => {
            console.log('实时输出:', output.data)   // 每次 yield 触发一次
        },
    },
})

调用子进程的工具

工具在独立子进程里运行,Bun.spawn 的 stdout/stderr 会自动转发给 onToolOutput,不需要额外处理:

// tools/run-script.js
export default {
    name: 'run_script',
    description: '运行一个 shell 脚本',
    inputSchema: {
        type: 'object',
        properties: { command: { type: 'string' } },
        required: ['command'],
    },
    async execute(input) {
        const proc = Bun.spawn(['bash', '-c', input.command], {
            stdout: 'pipe',
            stderr: 'pipe',
        })
        await proc.exited
        return { exitCode: proc.exitCode }   // stdout/stderr 已经自动转发了
    },
}

一个文件导出多个工具

// tools/math.js
const add = {
    name: 'add',
    description: '加法',
    inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] },
    async execute(input) { return input.a + input.b },
}

const multiply = {
    name: 'multiply',
    description: '乘法',
    inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] },
    async execute(input) { return input.a * input.b },
}

export default [add, multiply]    // 导出数组

工具是按名字找到的,不是按它在数组里排第几。所以往文件里插入新工具、调换顺序都不会让模型调错工具。

工具目录里可以放共享代码

扫描时只把"形状对得上"(有 name、有 execute)的东西注册成工具,其余文件直接跳过。
所以下面这些都可以和工具放在同一个目录里,不会影响扫描:

tools/
├── read.js          ← 工具
├── write.js         ← 工具
├── shared.js        ← 共享辅助函数,没有默认导出,自动跳过
└── read.test.js     ← 测试文件,自动跳过

跳过的前提是这些文件能被加载。目录里任何一个 .js 有语法错误、或者 import 了装不上的依赖,
scan 会当场抛出来——这是有意的:工具目录是你自己的代码,坏了就该立刻知道,
静默跳过只会变成"某个工具莫名其妙不见了"。

主动停止 Agent 的工具

如果某个工具代表"任务完成",可以让它返回 stop: true,Agent 循环会立刻停止:

// tools/finish.js
export default {
    name: 'finish',
    description: '任务完成时调用这个工具',
    inputSchema: {
        type: 'object',
        properties: { summary: { type: 'string', description: '完成情况总结' } },
        required: ['summary'],
    },
    async execute(input) {
        return {
            stop: true,                        // 告诉 Loop 停止循环
            output: { type: 'text', value: input.summary },
        }
    },
}

返回图片的工具

工具可以直接返回一个成形的输出块。截图、生成图表这类工具就靠这个把图交给模型:

// tools/screenshot.js
export default {
    name: 'screenshot',
    description: '截取当前屏幕',
    inputSchema: { type: 'object', properties: {} },
    async execute() {
        const png = await grabScreen()          // 你自己的截图实现,拿到 base64
        return {
            output: {
                type: 'content',                // 多模态输出块
                value: [
                    { type: 'text', text: '当前屏幕:' },
                    { type: 'file', mediaType: 'image/png', data: { type: 'data', data: png } },
                ],
            },
        }
    },
}

content 块里只能放 text / file / file-data / file-url 四种部件。放别的(比如旧版 AI SDK 的 media)会被工具进程当场挡住、变成一条普通的工具失败——这是有意的:非法块一旦穿过去写进 history,AI SDK 会在本地校验时抛错、请求根本发不出去、重试也认不出来,而 history 只增不删,于是之后每次 send 都撞同一堵墙,重启装回历史也一样。

output 里给的块会被原样交给模型,不会再被套一层。maxToolOutput 的截断只作用于文字块——图截一刀就彻底废了,所以媒体内容一个字节都不动。

工具权限询问

在敏感操作前可以要求用户确认:

const agent = Agent.create({
    // ...
    callbacks: {
        onPermission: async ({ toolName, arguments: args }) => {
            // 只有 delete_file 工具需要确认
            if (toolName === 'delete_file') {
                console.log(`即将删除文件: ${args.path}`)
                // 实际项目里这里可以弹窗或读取用户输入
                return true    // 返回 true 允许,false 拒绝
            }
            return true        // 其他工具直接放行
        },
    },
})

API 参考

Agent

从包里导入的唯一入口:

import Agent from '@kernel4632/agent-core'

Agent.create(options?)

创建一个独立的 Agent 实例,多个实例互不影响。

参数 类型 说明
options.id string Agent ID,默认自动生成
options.history array 初始历史消息,默认 []
options.config object 模型配置,见下表
options.tools object Agent.tool.scan() 的返回值
options.skills object Agent.skill.scan() 的返回值;不传就不启用技能
options.callbacks object 回调函数集合,见下表

config 字段:

字段 默认值 说明
baseURL '' 模型服务地址;model 是字符串时必填
apiKey '' 本包创建模型连接时使用的 API 密钥
model '' 模型名称(需要 baseURL),或 AI SDK 创建的模型实例(自带连接配置)
protocol 'chat' 本包创建模型连接时使用的协议:chat / responses / anthropic / gemini
system '' 系统提示词
stream true 是否流式请求模型。默认流式,await send 仍拿到完整结果;设为 false 才走非流式的旧式请求
cache true 提示词缓存,默认开启。长会话的固定开头(system、工具、历史)会被服务端缓存,命中就是省时间和省钱
toolMode 'native' 默认只用原生工具,system 零注入。接纯对话模型时主动打开 'text'(模拟工具)或 'auto'(兼容降级),见下方「让没有原生工具的模型也能用工具」
capabilities 见下方 按模型能力逐项开关图片、音频、视频、文件、工具调用、结构化输出、toolChoice 和思考内容
mediaFallback 'error' 媒体能力关闭时的处理方式;改成 'strip' 后保留文字并丢掉不支持的媒体
provider {} AI SDK 的生成参数,整份交给 AI SDK;不设时用模型自己的默认值
maxToolOutput undefined 默认不截断工具输出;主动设置后超出部分从中间截断并告知模型
maxTokens undefined 默认不估算或压缩上下文;主动设置后超过预算触发自动压缩
compactThreshold 0.8 压缩触发比例,0.8 表示到达 80% 时压缩
compact undefined 压缩单独用一套模型时写在这里,例如 { model: '便宜的小模型' };不写就和主模型共用
output undefined 结构化输出格式,例如 Agent.output.object({ schema });不写就返回普通文字
skills undefined Agent.skill.scan() 的返回值。默认零注入:不传、或目录里一个技能都没有,system 一个字都不多、也不挂内置 skill 工具。扫到技能才会注入列表并挂上 skill 工具
maxSteps undefined 默认不限制模型轮数;主动设置正整数后,到上限先保存这一轮的工具结果,再返回 step-limit
maxToolConcurrency undefined 默认不限制同一轮工具并发;主动设置后超出的调用排队
retryMaxDelay undefined 默认不限制单次退避时间;主动设置后限制毫秒数
retryMaxElapsed undefined 默认不限制重试总时长;主动设置后到点把错误交给上层(毫秒)
requestTimeout undefined 默认不限制单笔请求时长;主动设置毫秒数后,卡住的一笔会被中断
noToolPrompt undefined 默认不插入催促消息;主动设置后模型连续 noToolRounds 轮不调工具时的前一轮使用
noToolRounds 1 连续多少轮不调工具就结束一次 send(默认 1,模型不调工具即结束);设成 Infinity 就永不因不调工具结束

陌生中转站建议先使用默认能力。遇到只支持文字、但接口声称兼容 OpenAI 的模型,可以按能力关闭:

const agent = Agent.create({
    config: {
        baseURL: 'https://api.example.com/v1', model: 'model-name',
        capabilities: {
            image: false, audio: false, video: false, file: false,
            tools: false, structuredOutput: false, toolChoice: false,
            reasoning: false,
        },
        mediaFallback: 'strip',
    },
})

image、audio、video 和 file 控制内容块;tools 控制是否发送工具描述;structuredOutput 控制是否发送 response_format;toolChoice:false 让请求完全省略 tool_choice;reasoning:true 才会把历史里的思考块发给模型。旧式 image、audio、video 内容块会在真正请求模型时转换成 AI SDK 当前使用的 file,历史数组仍保留原始形状。

provider 直接放 AI SDK 的生成参数,例如:

const agent = Agent.create({
    config: {
        baseURL: 'https://api.example.com/v1', apiKey: 'sk-xxx', model: 'model-name',
        provider: {
            temperature: 0.3, topP: 0.9, maxOutputTokens: 4096,
            toolChoice: 'auto',                 // 默认 auto;不传时由底层使用 auto
            providerOptions: { openai: {} },    // 厂商专用设置直接交给 AI SDK
            headers: { 'X-App': 'example' },    // 额外请求头
            body: { custom_field: true },      // 额外请求体字段
        },
    },
})

在 agent.send({ input, config: { provider: { ... } } }) 里传入时,provider 整份替换原值,其他未传的配置字段继续保留。maxTokens 是上下文预算,和 provider.maxOutputTokens(单次生成量)不是一回事。

cache 默认开启。四种协议各按自己的方式让服务端复用固定的开头(system、工具描述、历史),命中后那部分不再重新计算,长会话能明显变快、变便宜:

协议 发的字段
chat / responses prompt_cache_key,服务端按这个键把同一台 Agent 的连续请求路由到同一处缓存
anthropic 在 system 和最后一条消息上打 cache_control 断点;Anthropic 不会自动缓存,不打就是 0%
gemini 隐式缓存由服务端自己决定,请求里没有可发的字段

实测(同一会话的多步工具循环,默认配置):chat 从第二步起命中约 99%,整个任务约 80%;anthropic 从第三步起每步命中整段开头,整个任务约 60%。键默认由 baseURL + model + system 推出,同一台 Agent 的连续请求自然落在同一个键上。也可以传对象自定义:

cache: {
    key: 'project:conversation-1',
    retention: '1h',
    body: { cache_namespace: 'agent' },
}

个别中转站不认这组字段、直接返回 400,那时设 cache: false 关掉,或把供应商自己的字段放进 provider.body。

让没有原生工具的模型也能用工具

有的中转站模型只会对话:请求里一带 tools 字段就报错。也有的模型接口支持工具,模型自己却不会用,把调用当成一段文字写了出来。这两种情况各有一个兼容开关,默认都关着:

toolMode 行为 会不会改 system
'native'(默认) 只用原生工具字段,文字里的调用一律当成普通回答 不会,你写的 system 原样发出
'text' 模拟工具:不发 tools 字段,把工具说明写进 system,从模型的文字里读回调用 会,追加一段工具说明
'auto' 兼容降级:先用原生工具;接口拒收工具字段时,改用文字协议重发,并记住这个模型;模型没走原生调用、却在文字里写了调用时,也读出来执行 只在被拒收、改用文字协议之后才会

这个包的原则是默认零注入:不打开开关,发给模型的 system 就是你写的那段,一个字都不多。接特殊 API 时缺什么就打开什么。

文字协议的格式(参考 Roo Code / Cline 的文本工具调用):

<tool_call>
{"name": "add", "arguments": {"a": 1, "b": 2}}
</tool_call>

工具结果用 <tool_result name="add">…</tool_result> 作为一条 user 消息送回模型。读取时尽量宽容:一次回复里写多个调用、arguments 写成字符串、外面套一层 {"type":"function","function":{…}}、参数平铺在同一层、带 Markdown 代码围栏、多一个尾逗号、最后一块没闭合,都能读出来。Roo Code 风格的 XML 写法(<read_file><path>a.js</path></read_file>)也认,会按工具的 schema 把 "7" 还原成数字 7。JSON 坏到读不出来时,会生成一条无效调用告诉模型重写,不会悄悄当成普通回答。模型自己编了 <tool_result> 时,从那里往后的内容全部丢掉,免得假结果混进历史。

历史始终是标准形状。 文字协议只在发请求和读响应的时候转换,agent.history 里存的永远是标准的 tool-call 块和 tool 消息。同一台 Agent 中途从纯对话模型换成原生工具模型(或反过来),历史直接接着用。

流式输出时,文字协议的 <tool_call> 原文也会出现在 onLLMEvent 的 text-delta 里,前端想隐藏可以按这个标签过滤。result.text 和历史里的文字已经去掉了调用部分。

需要包内没有预设的 Provider 或模型中间件时,直接传 AI SDK 模型实例;实例由调用方创建,baseURL、apiKey、protocol 就不必重复写。下面的进阶用法需要调用方安装对应的 Provider 包:

import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import Agent from '@kernel4632/agent-core'

const model = createOpenAICompatible({
    name: 'custom', baseURL: 'https://api.example.com/v1', apiKey: 'sk-xxx',
}).chatModel('model-name')
const agent = Agent.create({ config: { model, provider: { temperature: 0.3 } } })
const answer = await agent.send('你好')
console.log(answer.text)

模型实例的额外请求头仍可写在 provider.headers;provider.body 和自定义的 cache 对象需要本包创建连接,不能用于已创建的模型实例,设置时会明确报错。默认的 cache: true 对模型实例会安静跳过——实例的缓存设置由创建它的人负责。

provider.toolChoice 保持 auto 时,模型才能在任务做完后正常收尾,{ reason: 'no-tool' } 这个结束方式也才有意义。
改成 required 会强制模型每轮都调工具,而且部分服务(实测 gpt-oss-120b)在模型不想调工具时会直接返回 tool_use_failed。

callbacks 回调:

回调 触发时机 参数
onStart 循环开始 无
onLLMStart 每次请求模型前 { messages, tools }
onLLMFinish 模型请求完成 LLM 返回的完整结果
onLLMEvent 流式事件(每个 token) AI SDK 原生事件
onPermission 工具执行前 { toolName, arguments, sessionId } → 返回 true/false
onToolCall 工具即将执行 { toolCallId, toolName, input }
onToolOutput 工具有流式输出 { tool, stream, data, toolCallId, toolName }
onToolResult 工具执行完成 { toolName, output, ... }
onStep 一轮模型和工具都完成后 { step, result, toolCalls, toolResults }
onRetry 请求失败重试 { attempt, error, delay }(主请求和压缩请求共用)
onCompact 上下文压缩 compact-start / AI SDK 事件 / compact-finish

agent.send(input) / agent.send(options)

只发送文字或内容块数组时直接传入;要覆盖配置、历史、工具或回调时传对象。如果上一次 send 还在运行,本次调用会先自动停止上一次任务,再启动新任务。

await agent.send('继续')

const result = await agent.send({
    input: '帮我写个函数',          // 用户输入(必填)。也可以是内容块数组,见下方"发图片"
    config: { model: '新模型' },    // 可选:覆盖部分配置
    history: [],                   // 可选:替换历史
    tools: newTools,               // 可选:替换工具集
    callbacks: { onLLMEvent: e => {} }, // 可选:合并回调
})

// result.text → 最后一轮模型生成的文字
// result.steps → 这次 send 一共请求了模型几轮
// result.usage → 整次 send 的用量合计:{ inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens }
//                算钱、看缓存命中直接读这里;cacheReadTokens / inputTokens 就是缓存命中率
// result.reason:
//   'no-tool'    → 没注册工具时一次回答结束;有工具时连续 noToolRounds 轮(默认 1)没调用工具才结束
//   'tool-stop'  → 某个工具返回了 stop: true
//   'step-limit' → 达到 maxSteps,当前工具结果已保存,下一次 send 可继续

send 的出错方式只有一种:返回的 Promise 拒绝。空输入、非法配置、取消、模型报错全都从这里出来,所以只需要写 .catch(),不用另外包同步的 try/catch。模型报错的 error.kind 见下方。

连续发送时,建议保存并处理旧任务的 Promise,避免出现未处理的中止错误:

const oldTask = agent.send({ input: '执行旧任务' })
const newTask = agent.send({ input: '改执行新任务' }) // 自动停止旧任务

await oldTask.catch(() => {}) // 旧任务可能以 AbortError 结束
const result = await newTask

发图片: input 除了字符串,也可以是 AI SDK 风格的内容块数组。截图、用户在 IM 里发来的图都走这条路。

await agent.send({
    input: [
        { type: 'text', text: '这张截图里哪个按钮是提交?' },
        { type: 'image', image: 'data:image/png;base64,' + png },   // 也可以传 URL 或 Uint8Array
    ],
})

能不能看懂取决于模型本身。实测 kimi-k2.6 可以,gpt-oss-120b 没有视觉能力、会直接报 content must be a string——是大声失败,不是静默忽略。

agent.stop()

停止正在运行的 Agent。

await agent.stop()   // 等待完全停止后返回 { ok: true }

流式与网页实时推送

send 内部默认就是流式(config.stream 默认 true),await 拿到的是和流式一样的结果。想实时拿到每一块,用 onLLMEvent:模型每吐一段就调用一次,事件是包内 AI SDK 的原生事件。

await agent.send({
    input: '帮我查一下',
    callbacks: {
        onLLMEvent: event => {
            if (event.type === 'text-delta') show(event.text) // 模型新吐的一段文字
        },
        onToolOutput: output => show(output.data),                  // 工具产生的实时输出
    },
})

网页要边生成边推给浏览器时,用同样的回调,把内容写进一个标准 Response 流:

// Bun.serve 的 fetch 里。开始请求,立刻把响应返回给浏览器,内容随后一块块写上。
app.get('/chat', async request => {
    const encoder = new TextEncoder()
    const stream = new ReadableStream({
        async start(controller) {
            const send = data => controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
            try {
                // send 里的 onLLMEvent 在流式过程中被反复调用,每段文字都及时写出去。
                const result = await agent.send({
                    input: await request.text(),
                    callbacks: {
                        onLLMEvent: event => { if (event.type === 'text-delta') send({ text: event.text }) },
                        onStep: () => send({ status: 'step-done' }),
                    },
                })
                send({ done: true, reason: result.reason })
            } catch (error) {
                send({ error: error.message, kind: error.kind })     // 可传输的错误描述和分类。
            } finally { controller.close() }
        },
    })
    return new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' } })
})

要取消某次运行,给 send 传 signal(如 request.signal)或调用 agent.stop(),两者都能终止。

模型请求失败时抛出的错误带一个稳定的 kind:aborted(用户取消)、auth(密钥或权限,401/403)、limit(限流,429)、timeout(单笔超时或 408)、server(5xx)、network(没连上)、request(其余 4xx)、unknown(服务返回成功状态码,但回答格式对不上,常见于不完全兼容的中转站)。上层靠它决定该换模型、该等一下还是该直接报错,不用去认 AI SDK 的内部错误形状。kind 只补充信息,能不能重试仍然只由错误自己的 isRetryable 决定。

agent.compact(options?)

手动压缩当前历史(会先停止正在进行的任务)。默认使用 Agent.create 中配置的 onCompact、onRetry;在本次调用中传入同名回调可以覆盖默认值。

const summary = await agent.compact({
    onCompact: event => console.log(event),
})
// summary 是压缩后的文本,同时会自动追加到 agent.history

agent.history

Agent 当前的完整历史消息数组,可直接读写。

agent.running

null 表示空闲;运行中时是 { controller, task } 对象。


Agent.tool

MCP 工具与本地工具混用

const remote = await Agent.tool.mcp({
    transport: { type: 'http', url: 'http://localhost:3000/mcp', headers: {} },
    prefix: 'web_', // 可选前缀,避免不同服务出现同名工具
})
const local = await Agent.tool.scan('./tools')
const agent = Agent.create({ config, tools: Agent.tool.merge(local, remote) })

本地服务使用 { type: 'stdio', command: 'bun', args: ['/absolute/path/server.js'], env: {} };远端也支持 SDK 的 SSE 连接配置。连接参数只收可序列化的数据,不收函数或客户端对象。signal 可取消工具发现,timeout 可主动设置发现和执行的超时。

服务端公开的三种原语都会变成同一张工具表里的条目,模型不需要区分来源:

服务端公开 变成什么
tools 每个远端工具一个本地工具,调用即执行
prompts 每个提示词模板一个工具,模型传参调用就取回这段提示
resources 合并成一个 read_resource 工具,参数是要读的 uri;可用地址写在工具描述里

服务端没有声明 prompts 或 resources 时不会去问,也不会多出用不上的工具。取回的提示词会带上 user: / assistant: 角色标签,便于模型判断这是谁说的话。

发现和执行都在工具子进程中进行。每次独立连接,结束时关闭;取消不会切断另一次调用的连接。本地 stdio 服务的直接进程会随执行进程终止,远端已完成的副作用不能撤回。本实现不共享跨调用的 MCP session;需要保持状态的服务应使用业务 ID。merge 同名时以后面的集合为准,工具描述和执行配置一起替换。

Agent.tool.scan(directory)

扫描目录(含子目录)里所有 .js / .mjs / .ts / .mts 文件,把其中形状对得上的注册成工具,其余文件跳过。Bun 直接执行 TypeScript,所以工具可以直接写成 .ts;只有类型、没有代码的 .d.ts 会被跳过。

Agent.tool.scan('./tools')                                // 路径字符串(相对宿主进程的当前目录)
Agent.tool.scan(new URL('./tools', import.meta.url))      // URL——嵌进别人项目时用这个
Agent.tool.scan(builtinDir, userDir)                      // 多个目录,后面的覆盖前面的同名工具

多目录那条顺带给了你一个免费的覆盖机制:内置工具目录在前、用户工具目录在后,用户放一个同名工具就能替换掉内置的,不需要任何注册逻辑。

const tools = await Agent.tool.scan('./tools')
// tools.schema   → 给模型看的工具描述,传给 agent config 或 Loop.run
// tools.handlers → 执行器用的处理表,Tool.execute 需要它

每次调用都是独立的,多个 Agent 可以各扫描各的目录,互不影响。

Agent.tool.execute(options)

直接执行一个工具(通常不需要手动调用,Agent 内部会调用)。

const result = await Agent.tool.execute({
    name: 'add',
    input: { a: 1, b: 2 },
    handlers: tools.handlers,
    signal: abortController.signal,   // 可选
    onOutput: output => {},           // 可选,接收流式输出
    limit: 32000,                     // 可选,输出字符上限,超出从中间截断;不传则不截断
})
// result.output → 工具输出,格式为 { type: 'text'|'json', value: ... }

Agent.llm

结构化结果

const agent = Agent.create({
    config: {
        baseURL, apiKey, model,
        output: Agent.output.object({                       // 要什么形状的结果,写在配置顶层
            schema: Agent.schema.object({ total: Agent.schema.number() }),
        }),
    },
})
const { output } = await agent.send('计算订单总数')
console.log(output.total)

Agent.output 是包内 AI SDK 的 Output,Agent.schema 是包内 Zod,无需另装一套。数组可用 Agent.output.array({ element: Agent.schema.string() }),普通 JSON 可用 Agent.output.json()。所选模型服务须支持对应输出格式。

有工具时先完成工具调用,再读取最终对象。工具轮、tool-stop 或轮数用尽不会凭空产生 output。最终对象校验成功立即结束;格式错误直接抛出,不当成网络故障无限重试。手动及自动压缩只生成文本总结,不继承任务的对象格式。流式和非流式都在最终结果上提供 output;流中仍会有生成时的文字片段。

直接使用底层 LLM,绕过 Agent 循环。

Agent.llm.chat(options)

const result = await Agent.llm.chat({
    baseURL: 'https://api.example.com/v1',
    apiKey: 'sk-xxx',
    model: 'gpt-4o',
    messages: [{ role: 'user', content: '你好' }],
    provider: { temperature: 0.3 },            // 生成参数原样转给 AI SDK
    stream: true,                              // 默认 true
    onLLMEvent: event => console.log(event),   // 流式事件回调
})
// result.text          → 模型回复的文字
// result.toolCalls     → 工具调用列表
// result.usage         → token 用量
// result.finishReason  → 停止原因

Agent.context

Agent.context.build(options)

把历史消息裁剪成模型能用的上下文,并估算 token 数。

const { messages, token } = Agent.context.build({
    history: agent.history,
    system: '你是助手',
    tools: tools.schema,
})
// messages → 可以直接传给 LLM.chat 的消息数组
// token    → 估算的 token 数(用 gpt-tokenizer 计算)

裁剪的最小单位是回合,不是消息。一个回合 = 模型的一次响应 + 它发起的全部工具调用 + 这些调用的结果,
永远同进同出。所以裁剪结果里不会出现"有调用没结果"或"有结果没调用"的残缺配对——那种序列会被
OpenAI 和 Anthropic 直接 400。

压缩时自动保留:用户最初的 3 个回合(保留原始目标)+ 最新总结 + 总结前最近 3 个回合 + 总结后所有回合。

模型的思考内容(reasoning)会留在 history 里供上层 UI 渲染,但不会回传给模型:
它是某一次响应的厂商产物,不是持久对话状态,回传还会被一些服务拒绝。


History(从 Agent.history 获取)

const History = Agent.history   // 从唯一入口拿,打包成单文件之后也一样

// 创建各种类型的历史块
History.user({ content: '你好' })
History.user({ content: [{ type: 'text', text: '这是什么' }, { type: 'image', image: dataUrl }] })  // 带图片
History.assistant({ content: '你好', toolCalls: [{ id: 'call-1', name: 'add', arguments: { a: 1, b: 2 } }] })
History.tool({ toolCallId: 'call-1', toolName: 'add', content: '3' })
History.compact({ content: '之前的对话总结...' })

读历史——渲染给用户看、数聊了几轮,这两件事的知识本来就在核心里,不用你照着内部结构重写:

History.render(agent.history)
// user: 帮我读一下配置
// assistant: [思考] 我来读 [调用 read {"path":"a.json"}]
// tool: [read 返回] {"port":8080}
// user: 这张图呢 [图片]

History.turns(agent.history)   // 折成回合:[[user], [assistant, tool], [user]]

turns() 的规则比看上去微妙——工具结果认的是发起它的 toolCallId,不是它在数组里排在谁后面。从数据库按 id 恢复会话时结果可能排在调用前面,自己推一遍很容易算错。Context.build() 裁剪上下文用的就是这同一份实现,所以"什么是一个回合"在整个包里只有一处定义。

想自己排版的,用 turns() 拿到回合,再按内容块类型(text / reasoning / tool-call / tool-result / image / file)自己拼。


Agent.skill

技能是"现成的操作步骤":把一段固定的流程写成文件放进目录,模型需要时再按名字加载。和工具一样按目录扫描,但默认零注入——没扫到技能,系统的提示词一个字节都不多,也不会多出任何内置工具。

skills/
├── create-mcp-server/
│   └── SKILL.md
└── review-pr/
    └── SKILL.md

每个 SKILL.md 开头是一小段 frontmatter,下面才是正文:

---
name: review-pr
description: 审查一个 PR 时使用。包含检查清单和固定话术。
---

# 审查 PR

1. 先跑测试……
2. 检查……

规则:

  • 技能名必须等于它所在的文件夹名(review-pr/ 里就写 name: review-pr),对不上会当场报错,不做无声的猜测。
  • description 要写清"什么时候该用这个技能",它会被注入系统提示词;正文不会。
  • 支持 YAML 的 | 和 > 写多行说明。

扫描和接入:

const skills = await Agent.skill.scan('./skills')               // 一个目录
const skills = await Agent.skill.scan(builtinDir, userDir)      // 多个目录,后面的覆盖前面的同名技能
const skills = await Agent.skill.scan(new URL('./skills', import.meta.url)) // 嵌进别人项目时用 URL

const agent = Agent.create({ config, tools, skills })

注入多少、什么时候注入:

  • 传了 skills 且扫到了技能:往系统提示词追加一段「可用技能」,每行一个技能名和它的 description;同时挂上一个内置的 skill 工具。模型判断当前任务和某个技能对得上时,用 skill 工具把那个技能的正文读进来,再照着做。一次只加载一个,正文不会提前塞进上下文。
  • 没传 skills、传了 null、或目录里一个技能都没有:system 原样发出,工具表里也没有 skill 工具。这就是默认状态。

技能要通过 skills 传进去,不要用 Agent.tool.merge 合进 tools:合进去只会多一个 skill 工具,技能列表不会注入 system,模型不知道有哪些技能可选。

skill 这个名字如果和你的某个工具重名,内置技能工具优先——别给工具起这个名字就行。


运行测试

bun test

测试在 tests/ 里按 Agent、模型、上下文、工具等模块分文件;模型测试使用本地模拟服务。

查看覆盖率:

bun test --coverage

打包成单文件

bun run build

只生成 dist/agent-core.js:Bun 运行时使用的依赖全部内联并压缩。同时把手写的类型声明 index.d.ts 复制成 dist/agent-core.d.ts,TypeScript 用户导入时有参数补全和类型检查。构建脚本在没有 node_modules 的临时目录中导入产物,实际运行一次工具和模型模拟请求,成功后才替换 dist/agent-core.js。npm 包只包含这份产物、README 和 package.json;npm pack / npm publish 前会自动构建。产物顶部写有版本号和提交号,压缩不是加密。

import Agent from './agent-core.js'

// 工具目录用 URL 指,永远相对你自己这份代码,不受宿主进程当前目录影响
const tools = await Agent.tool.scan(new URL('./tools', import.meta.url))
const agent = Agent.create({ config: { /* ... */ }, tools })

default 导出就是全部入口,嵌入方需要的东西都挂在上面:

Agent.version 包版本,排查问题时报得出来
Agent.create(...) 创建 Agent 实例
Agent.tool .scan() / .execute()
Agent.skill .scan() —— 扫描技能目录,见「Agent.skill」
Agent.history .user() / .assistant() / .tool() / .compact() 造消息块;.turns() / .render() 读历史
Agent.context .build()
Agent.compact .run()
Agent.llm .chat()

Agent.history 是嵌入时最常用的那个:把 IM 消息转成 user 块、往历史里塞一条系统通知、从数据库恢复会话,都要靠它造出格式正确的消息。

工具进程需要一个 bun 运行时。 普通 bun run 时用的就是宿主自己(process.execPath,不依赖 PATH);宿主被 bun build --compile 成单可执行文件时,会退回 PATH 上的 bun——这种分发方式需要目标机器装了 bun。

工具目录不会被打包——它本来就该是运行时扫描的,放文件即加功能这件事在打包后照样成立。

工具进程那一半(features/tool-process.js)在打包时会被当成文本内联进单文件,运行时通过 bun - 从 stdin 喂给一个新的子进程,
所以产物挪到任何目录都能正常执行工具,也不会往磁盘上写临时文件。
这也是 tool-process.js 里不能出现任何 import 的原因:它以匿名程序的身份运行,相对路径和裸包名会按进程当前目录解析,必然出错。
(用 stdin 而不是 bun -e:后者在 8KB 到 32KB 之间就会 ENAMETOOLONG,而工具进程源码已经接近这个量级。)


常见问题

Q:支持 Node.js 吗?

不支持,而且这是刻意的。工具进程用 Bun.spawn + IPC,扫描用 Bun.Glob,打包靠 import ... with { type: 'text' },都只有 Bun 有。


Q:支持哪些模型提供商?

通过 config.protocol 控制:

protocol 值 适用场景
chat(默认) OpenAI 以及任何兼容 OpenAI Chat 接口的中转站
responses OpenAI Responses API
anthropic Anthropic 官方接口
gemini Google Gemini 接口

Q:上下文太长会怎样?

上下文超过 config.maxTokens 的 80%(可用 compactThreshold 调整)时,Loop 会自动调用 Compact 把历史压缩成一段总结,然后继续运行。

压缩只往 agent.history 里追加一条总结,永远不删任何东西。 history 是唯一权威数据来源,该保留多少由持有它的你来决定——压缩控制的是"这一轮发给模型的内容有多大",不是"历史能留多少"。

压缩可以另配一套模型。 总结不需要主模型那么聪明,用便宜的小模型就够,写在 config.compact 里:

config: {
    baseURL: 'https://api.example.com/v1', apiKey: 'sk-xxx', model: '主模型',
    compact: { model: '便宜的小模型' },   // 也能一起换 baseURL / apiKey / provider
}

不写 compact 就和主模型共用。自动压缩和手动 agent.compact() 用的都是这一套。

最新的那条总结会折进 system,而不是当成一条用户消息塞进对话里,并且带一句"这是你自己之前做过的工作,数据已由工具确认"。裸的 role:'user' 总结会被模型读成"用户塞给我一张表",于是它从头重做整个任务——真实端点实测 gpt-oss-120b 改之前 1/6 能正确续跑,改之后 6/6。

保留多少旧内容是按预算算的,不是按条数:最初目标最多占 20%、总结前的现场最多占 30%,总结之后的新回合不受限。只按条数留的话,用户第一条消息粘一大段日志就能让压缩永远收敛不了(实测 120/120 轮压完仍超阈值)。放不下的最初目标不会丢——总结本身就被要求保留用户的原始目标,而总结在 system 里,永远不会被裁掉。

每轮最多压一次。压完还超限就照常发出去,由模型服务判断收不收;下一轮还超自然会再压。不会为了压到达标而连续调用模型——那样一轮能烧掉上千次请求。


Q:工具返回一个巨大的结果会怎样?

默认不截断(maxToolOutput 默认 undefined)。设置 config.maxToolOutput(字符数)后,超出的部分会从中间截断,头尾都保留,并插入一段明确的提示告诉模型"输出过长、请缩小范围或分页重新获取"。

截断只作用于文字。多模态输出块里的图片、文件一个字节都不动——截一刀就彻底废了,截图工具的返回值本来就大。

建议主动设置它(比如 maxToolOutput: 32000):实测一个返回 1MB 文本的工具(cat 一个日志文件就够了)= 31 万 token,超过大多数模型的整个上下文窗口,而且它会永久留在历史里——连压缩都救不回来,因为压缩本身要把这坨东西发给模型去总结。


Q:工具卡住不返回会怎样?

默认会一直等——因为阻塞型工具是被支持的正常用法:等 IM 消息、盯文件变化、守着一个长任务,这些工具就是要长期不返回。有全局超时反而会把它们全废掉。

需要超时保护的工具自己在工具文件里声明:

export default {
    name: 'fetch_page',
    description: '抓一个网页',
    timeout: 30000,        // 毫秒。超时后工具进程被直接杀掉,模型收到一条超时结果
    inputSchema: { /* ... */ },
    async execute(input) { /* ... */ },
}

agent.stop() 任何时候都能立刻掐断,不管工具声没声明超时。


Q:工具执行失败会让 Agent 崩溃吗?

不会。工具失败会被捕获,错误信息会作为工具结果告诉模型,模型可以自行决定是否重试或换一种方式。

这条覆盖得相当彻底:工具自己抛错、工具文件语法错误、工具返回了没法序列化的值(循环引用、函数、类实例)、
toModelOutput 自己抛错、甚至工具调 process.exit 把整个工具进程干掉——
全都会变成一条模型能读的工具结果,而不是让这次调用永远挂着。


Q:多个工具是串行还是并行执行的?

并行。模型在一轮里要求调用多个工具时,所有工具同时开跑,结果按原顺序收集后一起写回历史。

同一个 Agent 内同时最多跑 config.maxToolConcurrency 个(默认不限制,一个工具独占一个工具进程),超出的排队。模型偶尔会一轮返回几十个工具调用,建议设一个上限(比如 8)避免瞬间起几十个进程。不同 Agent 各自计算,互不影响。

注意阻塞型工具会长期占着名额:上限设成 8、又有 8 个会话各挂一个 wait_for_message,第 9 个会话的任何工具都排不进来。多会话的 bot 要按会话数把这个值调大。


Q:怎么让 Agent 在任务完成时自动停止?

有两种方式:

  1. 写一个 finish 工具,让它 return { stop: true },并在系统提示词里告诉模型"完成任务时调用 finish 工具"。
  2. 不写 finish 工具,依赖默认行为:模型连续 noToolRounds 轮(默认 1)不调用任何工具时,Loop 自动返回 { reason: 'no-tool' }。

Q:怎么中途停止 Agent?

const runPromise = agent.send({ input: '...' })

// 3 秒后强制停止
setTimeout(() => agent.stop(), 3000)

await runPromise.catch(() => {})    // stop() 会让 send() 以 AbortError 结束

或者在 onPermission 回调里返回 false 拒绝某次工具调用,但不会停止整个循环。

Yorumlar (0)

Sonuc bulunamadi