create-headless-agent

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Create Headless Agent

创建无头Agent

Scaffolds a headless agent in TypeScript targeting OpenRouter. The generated project uses
@openrouter/agent
for the inner loop (model calls, tool execution, stop conditions) and provides a clean programmatic shell: configuration, session management, tool definitions, and one or more entry points (CLI, HTTP server, MCP server, or library import). No terminal UI, no readline, no ANSI — just input in, result out.
在TypeScript中搭建面向OpenRouter的无头Agent。生成的项目使用
@openrouter/agent
处理内部循环(模型调用、工具执行、停止条件),并提供简洁的可编程框架:配置、会话管理、工具定义以及一个或多个入口点(CLI、HTTP服务器、MCP服务器或库导入)。无终端UI、无readline、无ANSI——仅输入数据,输出结果。

Prerequisites

前置条件



Decision Tree

决策树

User wants to...Action
Build a new headless agentPresent checklist below, follow Generation Workflow
Add tools to an existing agentRead references/tools.md, present tool checklist only
Add a moduleRead references/modules.md, generate the module
Add an entry pointRead references/entry-points.md, generate it

用户需求操作
构建新的无头Agent展示下方检查清单,遵循生成流程
为现有Agent添加工具阅读references/tools.md,仅展示工具检查清单
添加模块阅读references/modules.md,生成对应模块
添加入口点阅读references/entry-points.md,生成对应入口点

Interactive Feature Checklist

交互式功能检查清单

Present this as a multi-select checklist. Items marked ON are pre-selected defaults.
以多选清单形式展示。标记为ON的是预选中的默认项。

Entry Points (pick one or more)

入口点(可选一个或多个)

Entry PointDefaultDescription
CLIONargs/stdin to agent to stdout,
--json
for NDJSON
Library moduleON
import { runAgent } from './agent'
HTTP serverOFF
Bun.serve()
with SSE streaming
MCP serverOFFExpose as MCP tool via stdio
入口点默认状态描述
CLION参数/标准输入传入Agent,结果输出到标准输出,
--json
参数可输出NDJSON格式
库模块ON支持
import { runAgent } from './agent'
导入使用
HTTP服务器OFF基于
Bun.serve()
实现,支持SSE流式传输
MCP服务器OFF通过标准IO作为MCP工具暴露

OpenRouter Server Tools (server-side, zero implementation)

OpenRouter服务器端工具(服务器端执行,无需客户端实现)

ToolType stringDefault
Web Search
openrouter:web_search
ON
Web Fetch
openrouter:web_fetch
ON
Datetime
openrouter:datetime
ON
Image Generation
openrouter:image_generation
OFF
Server tools go in the
tools
array alongside user-defined tools. No client code needed — OpenRouter executes them. Docs: openrouter.ai/docs/guides/features/server-tools.
工具类型字符串默认状态
Web搜索
openrouter:web_search
ON
Web抓取
openrouter:web_fetch
ON
日期时间
openrouter:datetime
ON
图片生成
openrouter:image_generation
OFF
服务器端工具与用户自定义工具一同放入
tools
数组。无需客户端代码——由OpenRouter执行。文档:openrouter.ai/docs/guides/features/server-tools

User-Defined Tools (client-side, generated into src/tools/)

用户自定义工具(客户端执行,生成到src/tools/目录)

ToolDefaultDescription
File ReadONRead files with offset/limit
File WriteONWrite/create files, auto-create directories
File EditONSearch-and-replace with diff validation
Glob/FindONFile discovery by glob pattern
Grep/SearchONContent search by regex
Directory ListONList directory contents
Shell/BashONExecute commands with timeout and output capture
Custom Tool TemplateONEmpty skeleton for domain-specific tools
JS/TS REPLOFFPersistent Bun REPL
Sub-agent SpawnOFFDelegate tasks to child agents
View ImageOFFRead local images as base64
工具默认状态描述
文件读取ON支持按偏移量/限制读取文件
文件写入ON写入/创建文件,自动创建目录
文件编辑ON带差异验证的搜索替换功能
全局匹配/查找ON通过通配符模式发现文件
内容搜索ON通过正则表达式搜索内容
目录列表ON列出目录内容
Shell/BashON执行命令,支持超时和输出捕获
自定义工具模板ON用于特定领域工具的空骨架
JS/TS REPLOFF持久化Bun交互式解释器
子Agent生成OFF将任务委托给子Agent
图片查看OFF将本地图片读取为base64格式

Agent Modules (architectural components)

Agent模块(架构组件)

ModuleDefaultDescription
Session PersistenceONJSONL conversation log,
--no-session
to disable
Retry with BackoffONBuilt into agent.ts
Context CompactionOFFSummarize when context is long
Tool Result OffloadOFFPersist oversized tool outputs to disk, keep preview in context
System Prompt CompositionOFFDynamic instructions from context files
Tool Approval FlowOFFProgrammatic approve/reject
Structured Event LoggingOFFJSON events to stderr
Output Schema ValidationOFFZod schema constraining response
Webhook NotificationsOFFPOST on completion
模块默认状态描述
会话持久化ONJSONL格式对话日志,
--no-session
参数可禁用
退避重试ON内置在agent.ts中
上下文压缩OFF上下文过长时自动总结
工具结果转存OFF将过大的工具输出持久化到磁盘,仅在上下文中保留预览
系统提示词组合OFF基于上下文文件生成动态指令
工具审批流程OFF可编程的批准/拒绝机制
结构化事件日志OFF输出JSON格式事件到标准错误输出
输出Schema验证OFF使用Zod Schema约束响应格式
Webhook通知OFF任务完成时发送POST请求

CLI Output Mode (single-select, if CLI entry point is ON)

CLI输出模式(单选,仅当CLI入口点为ON时生效)

ModeDefaultDescription
TextONFinal response text to stdout
JSONOFFNDJSON event stream
QuietOFFExit code only

模式默认状态描述
文本ON最终响应文本输出到标准输出
JSONOFFNDJSON格式事件流
静默OFF仅输出退出码

Generation Workflow

生成流程

Before generating, ask the user what to name their agent. This name is used as:
  • the
    "name"
    field in
    package.json
  • the
    "bin"
    command (so
    bun link
    makes it a globally-invokable CLI)
  • the project directory name (if creating a new directory)
Suggested question: "What would you like to call your agent? (short kebab-case, e.g.
research-bot
or
docs-helper
)"
. Validate the answer is a valid npm package name (lowercase, kebab-case, no spaces). Default to
my-agent
if the user has no preference. Use the chosen name everywhere the workflow below shows
<agent-name>
.
After getting the name and checklist selections, follow this workflow:
- [ ] Generate package.json with name=<agent-name> and bin={"<agent-name>": "src/cli.ts"}
- [ ] Generate tsconfig.json (Bun-native)
- [ ] Generate src/config.ts
- [ ] Generate src/tools/index.ts wiring selected tools
- [ ] Generate selected tool files in src/tools/ (specs in references/tools.md)
- [ ] Generate src/agent.ts (core runner)
- [ ] If Session Persistence ON: generate src/session.ts (spec in references/modules.md)
- [ ] Generate selected modules (specs in references/modules.md)
- [ ] Generate src/cli.ts entry point with shebang `#!/usr/bin/env bun` (spec in references/entry-points.md)
- [ ] If HTTP server selected: generate src/server.ts (spec in references/entry-points.md)
- [ ] If MCP server selected: generate src/mcp-server.ts (spec in references/entry-points.md)
- [ ] Generate .env.example
- [ ] Generate test/agent.test.ts
- [ ] Run `bun install` to fetch dependencies
- [ ] Verify: run `bunx tsc --noEmit`
- [ ] Run `bun link` inside the project to register <agent-name> globally
- [ ] Verify the command is on PATH: `command -v <agent-name>` should print a path. If it fails, tell the user to add Bun's bin dir to their shell rc:
      `export PATH="$HOME/.bun/bin:$PATH"` (for bash/zsh). `bun link` silently succeeds even when `~/.bun/bin` isn't on PATH, so without this check the user will be told the agent is globally available but `command not found` will greet them.
- [ ] Tell the user they can now invoke their agent from anywhere with `<agent-name> "<prompt>"`
- [ ] Optional: run `npx skills-ref validate .` to check SKILL.md frontmatter (if installed)
After generation, the user can run their agent from any directory:
bash
<agent-name> "What's in this repo?"
echo "Summarize README.md" | <agent-name>
<agent-name> --json "List all TODOs" | jq .
To later rename the agent, update the
name
and
bin
keys in
package.json
, then run
bun unlink && bun link
.

生成前,询问用户为Agent命名。该名称将用于:
  • package.json
    中的
    "name"
    字段
  • "bin"
    命令(执行
    bun link
    后可全局调用CLI)
  • 项目目录名称(若创建新目录)
建议提问:"您想为Agent起什么名字?(简短的短横线分隔格式,例如
research-bot
docs-helper
)"
。验证答案为有效的npm包名称(小写、短横线分隔、无空格)。若用户无偏好,默认使用
my-agent
。在以下流程中所有显示
<agent-name>
的位置使用选定的名称。
获取名称和检查清单选择后,遵循以下流程:
- [ ] 生成package.json,其中name=<agent-name>,bin={"<agent-name>": "src/cli.ts"}
- [ ] 生成tsconfig.json(Bun原生配置)
- [ ] 生成src/config.ts
- [ ] 生成src/tools/index.ts,关联选定的工具
- [ ] 在src/tools/目录生成选定的工具文件(参考references/tools.md中的规范)
- [ ] 生成src/agent.ts(核心运行器)
- [ ] 若会话持久化为ON:生成src/session.ts(参考references/modules.md中的规范)
- [ ] 生成选定的模块(参考references/modules.md中的规范)
- [ ] 生成src/cli.ts入口点,首行添加shebang `#!/usr/bin/env bun`(参考references/entry-points.md中的规范)
- [ ] 若选择HTTP服务器:生成src/server.ts(参考references/entry-points.md中的规范)
- [ ] 若选择MCP服务器:生成src/mcp-server.ts(参考references/entry-points.md中的规范)
- [ ] 生成.env.example
- [ ] 生成test/agent.test.ts
- [ ] 执行`bun install`拉取依赖
- [ ] 验证:执行`bunx tsc --noEmit`
- [ ] 在项目内执行`bun link`,全局注册<agent-name>
- [ ] 验证命令是否在PATH中:`command -v <agent-name>`应输出路径。若失败,告知用户将Bun的bin目录添加到shell配置文件:
      `export PATH="$HOME/.bun/bin:$PATH"`(适用于bash/zsh)。`bun link`会静默成功,即使`~/.bun/bin`不在PATH中,若不进行此检查,用户会被告知Agent已全局可用,但实际调用时会出现`command not found`错误。
- [ ] 告知用户现在可从任意目录调用Agent:`<agent-name> "<prompt>"`
- [ ] 可选:若已安装,执行`npx skills-ref validate .`检查SKILL.md前置内容
生成完成后,用户可从任意目录运行Agent:
bash
<agent-name> "这个仓库里有什么内容?"
echo "总结README.md" | <agent-name>
<agent-name> --json "列出所有待办事项" | jq .
后续若要重命名Agent,更新
package.json
中的
name
bin
字段,然后执行
bun unlink && bun link

Tool Pattern

工具模式

All user-defined tools follow this pattern using
@openrouter/agent/tool
. Here is one complete example — all other tools in references/tools.md follow the same shape:
typescript
import { tool } from '@openrouter/agent/tool';
import { z } from 'zod';

const DEFAULT_LINE_LIMIT = 2000;
const MAX_LINE_CHARS = 2000;

export const fileReadTool = tool({
  name: 'file_read',
  description:
    'Read the contents of a file. Output is capped at 2000 lines by default (use offset/limit to paginate) and any line longer than 2000 characters is truncated. When the response is truncated, the hint field tells you how to continue.',
  inputSchema: z.object({
    path: z.string().describe('Absolute path to the file'),
    offset: z.number().optional().describe('Start reading from this line (1-indexed)'),
    limit: z.number().optional().describe(`Maximum lines to return (default ${DEFAULT_LINE_LIMIT})`),
  }),
  execute: async ({ path, offset, limit }) => {
    try {
      const content = await Bun.file(path).text();
      const lines = content.split('\n');
      const start = offset ? offset - 1 : 0;
      const end = Math.min(start + (limit ?? DEFAULT_LINE_LIMIT), lines.length);
      let longLines = 0;
      const slice = lines.slice(start, end).map((line) => {
        if (line.length <= MAX_LINE_CHARS) return line;
        longLines++;
        return line.slice(0, MAX_LINE_CHARS) + `… [line truncated, ${line.length - MAX_LINE_CHARS} chars dropped]`;
      });
      const tailTruncated = end < lines.length;
      const truncated = tailTruncated || longLines > 0;
      const hintParts: string[] = [`Showing lines ${start + 1}-${end} of ${lines.length}.`];
      if (tailTruncated) hintParts.push(`Use offset=${end + 1} to continue.`);
      if (longLines > 0) hintParts.push(`${longLines} line(s) exceeded ${MAX_LINE_CHARS} chars and were per-line truncated; use grep to fetch content from those lines.`);
      return {
        content: slice.join('\n'),
        totalLines: lines.length,
        ...(truncated && {
          truncated: true,
          ...(tailTruncated && { nextOffset: end + 1 }),
          hint: hintParts.join(' '),
        }),
      };
    } catch (err: any) {
      if (err.code === 'ENOENT') return { error: `File not found: ${path}` };
      if (err.code === 'EACCES') return { error: `Permission denied: ${path}` };
      return { error: err.message };
    }
  },
});
For specs of all other tools, see references/tools.md.

所有用户自定义工具遵循
@openrouter/agent/tool
的模式。以下是完整示例——references/tools.md中的所有其他工具均遵循相同结构:
typescript
import { tool } from '@openrouter/agent/tool';
import { z } from 'zod';

const DEFAULT_LINE_LIMIT = 2000;
const MAX_LINE_CHARS = 2000;

export const fileReadTool = tool({
  name: 'file_read',
  description:
    '读取文件内容。默认输出上限为2000行(可使用offset/limit参数分页),超过2000字符的行会被截断。当响应被截断时,hint字段会告知您如何继续。',
  inputSchema: z.object({
    path: z.string().describe('文件的绝对路径'),
    offset: z.number().optional().describe('从该行开始读取(从1开始计数)'),
    limit: z.number().optional().describe(`返回的最大行数(默认值${DEFAULT_LINE_LIMIT}`),
  }),
  execute: async ({ path, offset, limit }) => {
    try {
      const content = await Bun.file(path).text();
      const lines = content.split('\n');
      const start = offset ? offset - 1 : 0;
      const end = Math.min(start + (limit ?? DEFAULT_LINE_LIMIT), lines.length);
      let longLines = 0;
      const slice = lines.slice(start, end).map((line) => {
        if (line.length <= MAX_LINE_CHARS) return line;
        longLines++;
        return line.slice(0, MAX_LINE_CHARS) + `… [行已截断,已丢弃${line.length - MAX_LINE_CHARS}个字符]`;
      });
      const tailTruncated = end < lines.length;
      const truncated = tailTruncated || longLines > 0;
      const hintParts: string[] = [`显示第${start + 1}-${end}行,共${lines.length}行。`];
      if (tailTruncated) hintParts.push(`使用offset=${end + 1}继续读取。`);
      if (longLines > 0) hintParts.push(`${longLines}行超过${MAX_LINE_CHARS}字符,已按行截断;使用grep工具获取这些行的内容。`);
      return {
        content: slice.join('\n'),
        totalLines: lines.length,
        ...(truncated && {
          truncated: true,
          ...(tailTruncated && { nextOffset: end + 1 }),
          hint: hintParts.join(' '),
        }),
      };
    } catch (err: any) {
      if (err.code === 'ENOENT') return { error: `文件未找到:${path}` };
      if (err.code === 'EACCES') return { error: `权限不足:${path}` };
      return { error: err.message };
    }
  },
});
所有其他工具的规范请查看references/tools.md

Core Files

核心文件

These files are always generated. The agent adapts them based on checklist selections.
这些文件会始终生成。Agent会根据检查清单的选择调整这些文件。

package.json

package.json

Initialize the project and install dependencies. Replace
<agent-name>
with the name the user chose:
bash
bun init -y
初始化项目并安装依赖。将
<agent-name>
替换为用户选定的名称:
bash
bun init -y

Then edit package.json:

然后编辑package.json:


```json
{
  "name": "<agent-name>",
  "type": "module",
  "bin": {
    "<agent-name>": "src/cli.ts"
  },
  "scripts": {
    "start": "bun run src/cli.ts",
    "dev": "bun --watch src/cli.ts",
    "build": "tsc --noEmit",
    "test": "bun test"
  },
  "dependencies": {
    "@openrouter/agent": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "@types/bun": "latest",
    "typescript": "latest"
  }
}
The
bin
entry is what makes the agent invokable by name after
bun link
. The target (
src/cli.ts
) must have a
#!/usr/bin/env bun
shebang on the first line.

```json
{
  "name": "<agent-name>",
  "type": "module",
  "bin": {
    "<agent-name>": "src/cli.ts"
  },
  "scripts": {
    "start": "bun run src/cli.ts",
    "dev": "bun --watch src/cli.ts",
    "build": "tsc --noEmit",
    "test": "bun test"
  },
  "dependencies": {
    "@openrouter/agent": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "@types/bun": "latest",
    "typescript": "latest"
  }
}
bin
项是执行
bun link
后Agent可通过名称调用的关键。目标文件(
src/cli.ts
)首行必须包含
#!/usr/bin/env bun
shebang。

tsconfig.json

tsconfig.json

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["bun-types"]
  },
  "include": ["src", "test"]
}
json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["bun-types"]
  },
  "include": ["src", "test"]
}

src/config.ts

src/config.ts

typescript
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';

function positiveNumber(name: string, raw: string): number {
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0) {
    throw new Error(`${name} must be a positive number, got: ${JSON.stringify(raw)}`);
  }
  return n;
}

export interface AgentConfig {
  apiKey: string;
  model: string;
  name: string;
  systemPrompt: string;
  maxSteps: number;
  maxCost: number;
  sessionDir: string;
  sessionEnabled: boolean;
  outputMode: 'text' | 'json' | 'quiet';
}

const DEFAULTS: AgentConfig = {
  apiKey: '',
  model: 'anthropic/claude-sonnet-4.6',
  name: 'My Agent',
  systemPrompt: [
    'You are a coding assistant with access to tools for reading, writing, editing, and searching files, and running shell commands.',
    '',
    'Current working directory: {cwd}',
    '',
    'Guidelines:',
    '- Use your tools proactively. Explore the codebase to find answers instead of asking the user.',
    '- Keep working until the task is fully resolved before responding.',
    '- Do not guess or make up information — use your tools to verify.',
    '- Be concise and direct.',
    '- Show file paths clearly when working with files.',
    '- Prefer grep and glob tools over shell commands for file search.',
    '- When editing code, make minimal targeted changes consistent with the existing style.',
  ].join('\n'),
  maxSteps: 20,
  maxCost: 1.0,
  sessionDir: '.sessions',
  sessionEnabled: true,
  outputMode: 'text',
};

export function loadConfig(overrides: Partial<AgentConfig> = {}, opts?: { skipApiKey?: boolean }): AgentConfig {
  let config = { ...DEFAULTS };

  const configPath = resolve('agent.config.json');
  if (existsSync(configPath)) {
    const file = JSON.parse(readFileSync(configPath, 'utf-8'));
    config = { ...config, ...file };
  }

  if (process.env.OPENROUTER_API_KEY) config.apiKey = process.env.OPENROUTER_API_KEY;
  if (process.env.AGENT_MODEL) config.model = process.env.AGENT_MODEL;
  if (process.env.AGENT_MAX_STEPS) config.maxSteps = positiveNumber('AGENT_MAX_STEPS', process.env.AGENT_MAX_STEPS);
  if (process.env.AGENT_MAX_COST) config.maxCost = positiveNumber('AGENT_MAX_COST', process.env.AGENT_MAX_COST);

  config = { ...config, ...overrides };
  if (!config.apiKey && !opts?.skipApiKey) throw new Error('OPENROUTER_API_KEY is required.');
  return config;
}
typescript
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';

function positiveNumber(name: string, raw: string): number {
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0) {
    throw new Error(`${name}必须为正数,当前值:${JSON.stringify(raw)}`);
  }
  return n;
}

export interface AgentConfig {
  apiKey: string;
  model: string;
  name: string;
  systemPrompt: string;
  maxSteps: number;
  maxCost: number;
  sessionDir: string;
  sessionEnabled: boolean;
  outputMode: 'text' | 'json' | 'quiet';
}

const DEFAULTS: AgentConfig = {
  apiKey: '',
  model: 'anthropic/claude-sonnet-4.6',
  name: 'My Agent',
  systemPrompt: [
    '您是一名编码助手,可使用工具读取、写入、编辑和搜索文件,以及执行shell命令。',
    '',
    '当前工作目录:{cwd}',
    '',
    '准则:',
    '- 主动使用工具。探索代码库寻找答案,而非询问用户。',
    '- 在响应前持续工作直到任务完全解决。',
    '- 不猜测或编造信息——使用工具验证。',
    '- 简洁直接。',
    '- 处理文件时清晰展示文件路径。',
    '- 文件搜索优先使用grep和glob工具而非shell命令。',
    '- 编辑代码时,根据现有风格进行最小化的针对性修改。',
  ].join('\n'),
  maxSteps: 20,
  maxCost: 1.0,
  sessionDir: '.sessions',
  sessionEnabled: true,
  outputMode: 'text',
};

export function loadConfig(overrides: Partial<AgentConfig> = {}, opts?: { skipApiKey?: boolean }): AgentConfig {
  let config = { ...DEFAULTS };

  const configPath = resolve('agent.config.json');
  if (existsSync(configPath)) {
    const file = JSON.parse(readFileSync(configPath, 'utf-8'));
    config = { ...config, ...file };
  }

  if (process.env.OPENROUTER_API_KEY) config.apiKey = process.env.OPENROUTER_API_KEY;
  if (process.env.AGENT_MODEL) config.model = process.env.AGENT_MODEL;
  if (process.env.AGENT_MAX_STEPS) config.maxSteps = positiveNumber('AGENT_MAX_STEPS', process.env.AGENT_MAX_STEPS);
  if (process.env.AGENT_MAX_COST) config.maxCost = positiveNumber('AGENT_MAX_COST', process.env.AGENT_MAX_COST);

  config = { ...config, ...overrides };
  if (!config.apiKey && !opts?.skipApiKey) throw new Error('OPENROUTER_API_KEY为必填项。');
  return config;
}

src/tools/index.ts

src/tools/index.ts

Adapt imports based on checklist selections. This example includes all default-ON tools:
typescript
import { serverTool } from '@openrouter/agent';
import { fileReadTool } from './file-read.js';
import { fileWriteTool } from './file-write.js';
import { fileEditTool } from './file-edit.js';
import { globTool } from './glob.js';
import { grepTool } from './grep.js';
import { listDirTool } from './list-dir.js';
import { shellTool } from './shell.js';
import { myCustomTool } from './custom.js';

// `as const` unlocks full type inference for tool calls downstream.
// See: https://openrouter.ai/docs/agent-sdk/call-model/tools
export const tools = [
  // User-defined tools — executed client-side
  fileReadTool,
  fileWriteTool,
  fileEditTool,
  globTool,
  grepTool,
  listDirTool,
  shellTool,
  myCustomTool,

  // Server tools — executed by OpenRouter, no client implementation needed
  serverTool({ type: 'openrouter:web_search' }),
  serverTool({ type: 'openrouter:web_fetch' }),
  serverTool({ type: 'openrouter:datetime', parameters: { timezone: 'UTC' } }),
] as const;
根据检查清单选择调整导入内容。以下示例包含所有默认ON的工具:
typescript
import { serverTool } from '@openrouter/agent';
import { fileReadTool } from './file-read.js';
import { fileWriteTool } from './file-write.js';
import { fileEditTool } from './file-edit.js';
import { globTool } from './glob.js';
import { grepTool } from './grep.js';
import { listDirTool } from './list-dir.js';
import { shellTool } from './shell.js';
import { myCustomTool } from './custom.js';

// `as const`为下游工具调用解锁完整类型推断。
// 查看:https://openrouter.ai/docs/agent-sdk/call-model/tools
export const tools = [
  // 用户自定义工具——客户端执行
  fileReadTool,
  fileWriteTool,
  fileEditTool,
  globTool,
  grepTool,
  listDirTool,
  shellTool,
  myCustomTool,

  // 服务器端工具——由OpenRouter执行,无需客户端实现
  serverTool({ type: 'openrouter:web_search' }),
  serverTool({ type: 'openrouter:web_fetch' }),
  serverTool({ type: 'openrouter:datetime', parameters: { timezone: 'UTC' } }),
] as const;

src/agent.ts

src/agent.ts

typescript
import { OpenRouter } from '@openrouter/agent';
import type { Item } from '@openrouter/agent';
import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
import type { AgentConfig } from './config.js';
import { tools } from './tools/index.js';

export type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };

export type AgentEvent =
  | { type: 'text'; delta: string }
  | { type: 'tool_call'; name: string; callId: string; args: Record<string, unknown> }
  | { type: 'tool_result'; name: string; callId: string; output: string }
  | { type: 'reasoning'; delta: string }
  | { type: 'turn_end' }
  | { type: 'done'; usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | null | undefined; durationMs: number };

export async function runAgent(
  config: AgentConfig,
  input: string | ChatMessage[],
  options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal },
) {
  const startedAt = Date.now();
  const client = new OpenRouter({ apiKey: config.apiKey });

  const result = client.callModel({
    model: config.model,
    instructions: config.systemPrompt.replace('{cwd}', process.cwd()),
    input: input as string | Item[],
    tools,
    stopWhen: [stepCountIs(config.maxSteps), maxCost(config.maxCost)],
  });

  // Wire AbortSignal → result.cancel() so the underlying network stream
  // actually closes (not just the iterator we're about to walk). Also
  // handle the pre-aborted case: addEventListener('abort') does not fire
  // for signals already in the aborted state.
  const onAbort = () => result.cancel();
  options?.signal?.addEventListener('abort', onAbort);
  if (options?.signal?.aborted) result.cancel();

  // Draining getTextStream concurrently with getItemsStream reads the
  // stream dry, so getResponse().outputText ends up empty. We accumulate
  // text deltas here as a source of truth for the final text.
  let accumulatedText = '';

  try {
    if (options?.onEvent) {
      // Run two streams concurrently: getTextStream for text deltas (no
      // bookkeeping required) and getItemsStream filtered to tool events.
      // The SDK's ReusableReadableStream allows concurrent consumption.
      const callNames = new Map<string, string>();

      const streamText = async () => {
        for await (const delta of result.getTextStream()) {
          if (options?.signal?.aborted) break;
          options.onEvent!({ type: 'text', delta });
          accumulatedText += delta;
        }
      };

      const streamTools = async () => {
        for await (const item of result.getItemsStream()) {
          if (options?.signal?.aborted) break;
          if (item.type === 'function_call') {
            callNames.set(item.callId, item.name);
            if (item.status === 'completed') {
              const args = (() => { try { return item.arguments ? JSON.parse(item.arguments) : {}; } catch { return {}; } })();
              options.onEvent!({ type: 'tool_call', name: item.name, callId: item.callId, args });
            }
          } else if (item.type === 'function_call_output') {
            const out = typeof item.output === 'string' ? item.output : JSON.stringify(item.output);
            options.onEvent!({
              type: 'tool_result',
              name: callNames.get(item.callId) ?? 'unknown',
              callId: item.callId,
              output: out.length > 200 ? out.slice(0, 200) + '...' : out,
            });
            // Signal a turn boundary; consumers (e.g. CLI text mode) can
            // render a separator. Keeps presentation out of agent.ts.
            options.onEvent!({ type: 'turn_end' });
          } else if (item.type === 'reasoning') {
            const text = item.summary?.map((s: { text: string }) => s.text).join('') ?? '';
            if (text) options.onEvent!({ type: 'reasoning', delta: text });
          }
        }
      };

      await Promise.all([streamText(), streamTools()]);
    }

    const response = await result.getResponse();
    const durationMs = Date.now() - startedAt;
    const text = accumulatedText || (response.outputText ?? '');
    options?.onEvent?.({ type: 'done', usage: response.usage, durationMs });
    return { text, usage: response.usage, output: response.output, durationMs };
  } finally {
    options?.signal?.removeEventListener('abort', onAbort);
  }
}

/**
 * Retry on 429/5xx — but ONLY if no tool calls have been executed yet.
 * Once a mutating tool (file_write, shell, etc.) has run, replaying the
 * whole agent from the initial prompt would double-execute side effects.
 * For mid-run resilience, use a StateAccessor (see references/modules.md).
 */
export async function runAgentWithRetry(
  config: AgentConfig,
  input: string | ChatMessage[],
  options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal; maxRetries?: number },
) {
  for (let attempt = 0, max = options?.maxRetries ?? 3; attempt <= max; attempt++) {
    let toolCallsMade = 0;
    const wrappedOptions = {
      ...options,
      onEvent: (event: AgentEvent) => {
        if (event.type === 'tool_call') toolCallsMade++;
        options?.onEvent?.(event);
      },
    };
    try {
      return await runAgent(config, input, wrappedOptions);
    } catch (err: any) {
      const s = err?.status ?? err?.statusCode;
      const retryable = s === 429 || (s >= 500 && s < 600);
      if (!retryable || attempt === max || toolCallsMade > 0) throw err;
      await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 30000)));
    }
  }
  throw new Error('Unreachable');
}
typescript
import { OpenRouter } from '@openrouter/agent';
import type { Item } from '@openrouter/agent';
import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
import type { AgentConfig } from './config.js';
import { tools } from './tools/index.js';

export type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };

export type AgentEvent =
  | { type: 'text'; delta: string }
  | { type: 'tool_call'; name: string; callId: string; args: Record<string, unknown> }
  | { type: 'tool_result'; name: string; callId: string; output: string }
  | { type: 'reasoning'; delta: string }
  | { type: 'turn_end' }
  | { type: 'done'; usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | null | undefined; durationMs: number };

export async function runAgent(
  config: AgentConfig,
  input: string | ChatMessage[],
  options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal },
) {
  const startedAt = Date.now();
  const client = new OpenRouter({ apiKey: config.apiKey });

  const result = client.callModel({
    model: config.model,
    instructions: config.systemPrompt.replace('{cwd}', process.cwd()),
    input: input as string | Item[],
    tools,
    stopWhen: [stepCountIs(config.maxSteps), maxCost(config.maxCost)],
  });

  // 将AbortSignal关联到result.cancel(),确保底层网络流实际关闭(而非仅终止我们要遍历的迭代器)。同时
  // 处理已终止的情况:addEventListener('abort')不会对已处于终止状态的信号触发。
  const onAbort = () => result.cancel();
  options?.signal?.addEventListener('abort', onAbort);
  if (options?.signal?.aborted) result.cancel();

  // 同时消费getTextStream和getItemsStream会耗尽流,导致getResponse().outputText为空。我们在此处
  // 累积文本增量作为最终文本的可靠来源。
  let accumulatedText = '';

  try {
    if (options?.onEvent) {
      // 同时运行两个流:getTextStream用于文本增量(无需记录),getItemsStream过滤工具事件。
      // SDK的ReusableReadableStream支持并发消费。
      const callNames = new Map<string, string>();

      const streamText = async () => {
        for await (const delta of result.getTextStream()) {
          if (options?.signal?.aborted) break;
          options.onEvent!({ type: 'text', delta });
          accumulatedText += delta;
        }
      };

      const streamTools = async () => {
        for await (const item of result.getItemsStream()) {
          if (options?.signal?.aborted) break;
          if (item.type === 'function_call') {
            callNames.set(item.callId, item.name);
            if (item.status === 'completed') {
              const args = (() => { try { return item.arguments ? JSON.parse(item.arguments) : {}; } catch { return {}; } })();
              options.onEvent!({ type: 'tool_call', name: item.name, callId: item.callId, args });
            }
          } else if (item.type === 'function_call_output') {
            const out = typeof item.output === 'string' ? item.output : JSON.stringify(item.output);
            options.onEvent!({
              type: 'tool_result',
              name: callNames.get(item.callId) ?? 'unknown',
              callId: item.callId,
              output: out.length > 200 ? out.slice(0, 200) + '...' : out,
            });
            // 标记轮次边界;消费者(如CLI文本模式)可渲染分隔符。将展示逻辑从agent.ts中分离。
            options.onEvent!({ type: 'turn_end' });
          } else if (item.type === 'reasoning') {
            const text = item.summary?.map((s: { text: string }) => s.text).join('') ?? '';
            if (text) options.onEvent!({ type: 'reasoning', delta: text });
          }
        }
      };

      await Promise.all([streamText(), streamTools()]);
    }

    const response = await result.getResponse();
    const durationMs = Date.now() - startedAt;
    const text = accumulatedText || (response.outputText ?? '');
    options?.onEvent?.({ type: 'done', usage: response.usage, durationMs });
    return { text, usage: response.usage, output: response.output, durationMs };
  } finally {
    options?.signal?.removeEventListener('abort', onAbort);
  }
}

/**
 * 429/5xx错误时重试——但仅在尚未执行任何工具调用时生效。
 * 一旦执行了可变工具(file_write、shell等),从初始提示词重新运行整个Agent会导致副作用重复执行。
 * 若要实现运行中的弹性,使用StateAccessor(查看references/modules.md)。
 */
export async function runAgentWithRetry(
  config: AgentConfig,
  input: string | ChatMessage[],
  options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal; maxRetries?: number },
) {
  for (let attempt = 0, max = options?.maxRetries ?? 3; attempt <= max; attempt++) {
    let toolCallsMade = 0;
    const wrappedOptions = {
      ...options,
      onEvent: (event: AgentEvent) => {
        if (event.type === 'tool_call') toolCallsMade++;
        options?.onEvent?.(event);
      },
    };
    try {
      return await runAgent(config, input, wrappedOptions);
    } catch (err: any) {
      const s = err?.status ?? err?.statusCode;
      const retryable = s === 429 || (s >= 500 && s < 600);
      if (!retryable || attempt === max || toolCallsMade > 0) throw err;
      await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 30000)));
    }
  }
  throw new Error('Unreachable');
}

src/cli.ts

src/cli.ts

Headless CLI entry point — parses args, reads stdin, dispatches to the agent, and exits. See references/entry-points.md for the complete implementation.
typescript
import { parseArgs } from 'util';
import { loadConfig } from './config.js';
import { runAgentWithRetry, type AgentEvent } from './agent.js';
import { initSessionDir, saveMessage, newSessionPath } from './session.js';

const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  options: {
    prompt: { type: 'string', short: 'p' },
    json: { type: 'boolean', short: 'j', default: false },
    quiet: { type: 'boolean', short: 'q', default: false },
    'no-session': { type: 'boolean', default: false },
    model: { type: 'string', short: 'm' },
    'max-steps': { type: 'string' },
    'max-cost': { type: 'string' },
    help: { type: 'boolean', short: 'h', default: false },
  },
  allowPositionals: true,
});

// ... resolve prompt from args, positional, or stdin
// ... call loadConfig with overrides
// ... call runAgentWithRetry with appropriate event handler
// ... exit with code 0 on success, 1 on error
See references/entry-points.md for the complete
src/cli.ts
,
src/server.ts
, and
src/mcp-server.ts
implementations.

无头CLI入口点——解析参数、读取标准输入、调度Agent并退出。完整实现请查看references/entry-points.md
typescript
import { parseArgs } from 'util';
import { loadConfig } from './config.js';
import { runAgentWithRetry, type AgentEvent } from './agent.js';
import { initSessionDir, saveMessage, newSessionPath } from './session.js';

const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  options: {
    prompt: { type: 'string', short: 'p' },
    json: { type: 'boolean', short: 'j', default: false },
    quiet: { type: 'boolean', short: 'q', default: false },
    'no-session': { type: 'boolean', default: false },
    model: { type: 'string', short: 'm' },
    'max-steps': { type: 'string' },
    'max-cost': { type: 'string' },
    help: { type: 'boolean', short: 'h', default: false },
  },
  allowPositionals: true,
});

// ... 从参数、位置参数或标准输入解析prompt
// ... 使用覆盖参数调用loadConfig
// ... 使用合适的事件处理器调用runAgentWithRetry
// ... 成功时以0码退出,错误时以1码退出
完整的
src/cli.ts
src/server.ts
src/mcp-server.ts
实现请查看references/entry-points.md

Reference Files

参考文件

For content beyond the core files:
  • references/tools.md -- Specs for all user-defined tools: file-read, file-write, file-edit, glob, grep, list-dir, shell, web-fetch, js-repl, sub-agent, view-image, custom template
  • references/modules.md -- Agent modules: session persistence, context compaction, system prompt composition, tool approval, structured logging, output schema validation, webhook notifications
  • references/entry-points.md -- Entry point specs: CLI (full implementation), HTTP server with SSE, MCP server via stdio
核心文件之外的内容请查看:
  • references/tools.md -- 所有用户自定义工具的规范:file-read、file-write、file-edit、glob、grep、list-dir、shell、web-fetch、js-repl、sub-agent、view-image、自定义模板
  • references/modules.md -- Agent模块:会话持久化、上下文压缩、系统提示词组合、工具审批、结构化日志、输出Schema验证、Webhook通知
  • references/entry-points.md -- 入口点规范:CLI(完整实现)、带SSE的HTTP服务器、通过标准IO的MCP服务器