Skip to content

开发 CLI Agent ​

本文档将指导你如何开发一个 CLI Agent,并通过 Docker 容器的方式接入 Mythra Agent Studio。

概述 ​

CLI Agent 是一种以命令行工具形式发布的 AI Agent 程序。它具有以下特点:

  • 封装在 Docker image 中: CLI Agent 打包成 Docker 镜像,便于分发和部署
  • 通过 stdio 通信: 使用标准输入(stdin)接收消息,标准输出(stdout)发送响应
  • 遵循消息协议: 必须遵循 Agent 消息协议 与前端进行通信
  • 运行在沙箱环境: CLI Agent 在沙箱(sandbox)环境中运行,可以访问沙箱文件系统

架构说明 ​

整体架构 ​

通信流程 ​

  1. 发现 Agent: 前端通过 /agent/:project_id/list 接口获取可用的 Agent 列表,其中包含 CLI Agent
  2. 建立连接: 用户选择 CLI Agent 后,前端与 agent-server 建立 WebSocket 连接
  3. 启动容器: agent-server 使用 docker run --rm -i 命令启动 Docker 容器
  4. 消息传递:
    • WebSocket 上行消息 → CLI Agent 的 stdin
    • CLI Agent 的 stdout → WebSocket 下行消息
    • CLI Agent 的 stderr → 日志文件
  5. 结束会话: Agent 处理完成后退出,容器自动删除

开发步骤 ​

1. 选择技术栈 ​

你可以使用任何支持标准输入输出的编程语言开发 CLI Agent。推荐选择:

语言推荐框架/库特点
Node.jsreadline、event-stream生态丰富、开发效率高
Pythonsys.stdin/stdout、asyncioAI 生态完善、适合机器学习场景
Gobufio.Scanner、os.Stdin高性能、部署简单
Ruststd::io::Stdin高性能、内存安全

本文以 Node.js 为例进行说明。

2. 创建项目 ​

创建一个新的 Node.js 项目:

bash
mkdir my-cli-agent
cd my-cli-agent
npm init -y
npm install openai

3. 实现消息处理 ​

创建 index.js 文件,实现基本的 stdin/stdout 通信:

javascript
const readline = require('readline');
const OpenAI = require('openai');

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
});

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
  terminal: false
});

function generateId() {
  return Math.random().toString(36).substring(2, 15);
}

function sendMessage(type, event, content = '', extra = {}) {
  const message = {
    id: generateId(),
    type,
    event,
    ...extra,
  };

  if (content) {
    message.content = content;
  }

  console.log(JSON.stringify(message));
}

async function processUserInput(userInput) {
  sendMessage('response-state', 'start');
  
  sendMessage('iteration-state', 'start', 'iteration 0');
  
  sendMessage('model-invocation-state', 'start');

  const chatId = generateId();
  sendMessage('chat-output', 'start');

  try {
    const stream = await openai.chat.completions.create({
      model: process.env.OPENAI_MODEL || 'gpt-4',
      messages: [{ role: 'user', content: userInput }],
      stream: true,
    });

    for await (const chunk of stream) {
      const content = chunk.choices[0]?.delta?.content || '';
      if (content) {
        sendMessage('chat-output', 'delta', content);
      }
    }

    sendMessage('chat-output', 'end');
  } catch (error) {
    sendMessage('error', 'error', error.message);
  }

  sendMessage('model-invocation-state', 'end');
  
  sendMessage('iteration-state', 'end', 'iteration 0');
  
  sendMessage('response-state', 'end');
}

rl.on('line', async (line) => {
  try {
    const message = JSON.parse(line);
    
    if (message.type === 'user-input') {
      await processUserInput(message.userInput);
    }
  } catch (error) {
    sendMessage('error', 'error', `Failed to process message: ${error.message}`);
  }
});

4. 创建 Dockerfile ​

创建 Dockerfile 文件:

dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install --production

COPY . .

CMD ["node", "index.js"]

5. 构建和测试 ​

构建 Docker 镜像:

bash
docker build -t my-cli-agent:latest .

本地测试:

bash
echo '{"type":"user-input","userInput":"你好","sessionId":"test-session"}' | \
  docker run --rm -i \
    -e OPENAI_API_KEY=your-api-key \
    -e OPENAI_BASE_URL=https://api.openai.com/v1 \
    -e OPENAI_MODEL=gpt-4 \
    my-cli-agent:latest

配置说明 ​

docker.yaml 配置格式 ​

CLI Agent 需要在 agent-server 的 etc/docker.yaml 配置文件中注册。配置格式如下:

yaml
builtin_agents:
  - id: my-cli-agent
    name: "我的CLI Agent"
    proto: MAP-V1
    image: my-cli-agent:latest
    command: node
    args:
      - index.js

配置字段说明:

字段类型必填说明
idstring是Agent 的唯一标识符,用于 API 调用
namestring是Agent 的显示名称,显示在前端 UI 中
protostring是协议版本,目前固定为 MAP-V1
imagestring是Docker 镜像名称
commandstring是容器启动命令
args[]string否命令参数列表

环境变量 ​

agent-server 会自动注入以下环境变量到 Docker 容器中:

环境变量说明
OPENAI_BASE_URLOpenAI API 的 Base URL
OPENAI_API_KEYOpenAI API Key
OPENAI_MODEL用户选择的模型名称
SESSION_ID会话 ID
RUN_ID本次运行的唯一 ID
TZ时区设置,默认为 Asia/Shanghai

挂载目录 ​

CLI Agent 运行时,沙箱目录会被挂载到容器的 /data 目录:

bash
-v /path/to/sandbox:/data

Agent 可以在 /data 目录中读写文件,这些文件会同步到用户的沙箱环境中。

消息协议 ​

CLI Agent 必须遵循 Agent 消息协议 进行通信。以下是关键要点:

输入消息格式 ​

从 stdin 接收的消息格式:

json
{
  "type": "user-input",
  "apiKey": "your-wds-api-key",
  "projectId": "2231",
  "workDir": "/data",
  "sessionId": "d271487972e2221eb3aa98bc1a7ae6fc",
  "userInput": "你好",
  "preferredModel": "火山引擎:doubao-seed-1-6-lite-251015"
}

输出消息格式 ​

向 stdout 输出的消息格式:

响应开始:

json
{"id":"abc123","type":"response-state","event":"start"}

聊天内容:

json
{"id":"def456","type":"chat-output","event":"start"}
{"id":"def456","type":"chat-output","event":"delta","content":"你好"}
{"id":"def456","type":"chat-output","event":"delta","content":"!"}
{"id":"def456","type":"chat-output","event":"end"}

响应结束:

json
{"id":"abc123","type":"response-state","event":"end"}

消息流示例 ​

一个完整的消息流示例:

stdin:  {"type":"user-input","userInput":"你好",...}
stdout: {"id":"a1","type":"response-state","event":"start"}
stdout: {"id":"b1","type":"iteration-state","event":"start","content":"iteration 0"}
stdout: {"id":"c1","type":"model-invocation-state","event":"start"}
stdout: {"id":"d1","type":"chat-output","event":"start"}
stdout: {"id":"d1","type":"chat-output","event":"delta","content":"你好"}
stdout: {"id":"d1","type":"chat-output","event":"delta","content":"!"}
stdout: {"id":"d1","type":"chat-output","event":"end"}
stdout: {"id":"c1","type":"model-invocation-state","event":"end"}
stdout: {"id":"b1","type":"iteration-state","event":"end","content":"iteration 0"}
stdout: {"id":"a1","type":"response-state","event":"end"}

进阶功能 ​

访问沙箱文件 ​

CLI Agent 可以直接访问挂载在 /data 目录下的沙箱文件:

javascript
const fs = require('fs');
const path = require('path');

const workDir = '/data';

function readFile(filename) {
  const filePath = path.join(workDir, filename);
  return fs.readFileSync(filePath, 'utf-8');
}

function writeFile(filename, content) {
  const filePath = path.join(workDir, filename);
  fs.writeFileSync(filePath, content, 'utf-8');
}

调用工具 ​

当 Agent 执行工具调用(如文件操作、命令执行等)时,可以发送 tool-call 消息来通知用户。这是一种通知机制,让用户了解 Agent 的工作过程,增强透明度和用户体验。

tool-call 消息会在前端 UI 中显示,用户可以看到 Agent 正在调用什么工具以及调用的参数。

javascript
function callTool(func, args) {
  const toolCallId = generateId();
  
  sendMessage('tool-call', 'start', '', {
    func,
    args,
  });
  
  const result = executeTool(func, args);
  
  sendMessage('tool-call', 'end', '', {
    func,
    args,
  });
  
  return result;
}

function executeTool(func, args) {
  if (func === 'ReadFile') {
    return fs.readFileSync(args.filePath, 'utf-8');
  } else if (func === 'WriteFile') {
    fs.writeFileSync(args.filePath, args.content, 'utf-8');
    return 'File written successfully';
  }
  throw new Error(`Unknown tool: ${func}`);
}

错误处理 ​

当发生错误时,发送 error 消息:

javascript
function sendError(errorMessage) {
  sendMessage('error', 'error', errorMessage);
}

会话记忆 ​

CLI Agent 的消息通信协议仅支持单次请求-响应模式(one round-trip)。用户发送一条消息,Agent 处理消息并返回响应,然后退出,agent-server 断开 WebSocket 连接。每一条消息可以视为一个独立的任务。

如果用户希望就同一个主题进行多次交互,客户端可以再次发起 WebSocket 连接并发送新的用户消息。为了保持会话的连续性,需要保证 sessionId 与上次对话一致。

Agent 可以根据 sessionId 来加载对应的会话记忆或会话上下文:

javascript
const fs = require('fs');
const path = require('path');

function loadSessionMemory(sessionId) {
  const memoryFile = path.join('/data', '.session', `${sessionId}.json`);
  try {
    if (fs.existsSync(memoryFile)) {
      const data = fs.readFileSync(memoryFile, 'utf-8');
      return JSON.parse(data);
    }
  } catch (error) {
    console.error(`Failed to load session memory: ${error.message}`);
  }
  return { messages: [] };
}

function saveSessionMemory(sessionId, memory) {
  const memoryDir = path.join('/data', '.session');
  if (!fs.existsSync(memoryDir)) {
    fs.mkdirSync(memoryDir, { recursive: true });
  }
  
  const memoryFile = path.join(memoryDir, `${sessionId}.json`);
  fs.writeFileSync(memoryFile, JSON.stringify(memory, null, 2), 'utf-8');
}

rl.on('line', async (line) => {
  try {
    const message = JSON.parse(line);
    
    if (message.type === 'user-input') {
      const sessionId = message.sessionId;
      const memory = loadSessionMemory(sessionId);
      
      memory.messages.push({
        role: 'user',
        content: message.userInput
      });
      
      await processUserInput(message.userInput, memory);
      
      memory.messages.push({
        role: 'assistant',
        content: lastResponse
      });
      
      saveSessionMemory(sessionId, memory);
    }
  } catch (error) {
    sendError(`Failed to process message: ${error.message}`);
  }
});

发布流程 ​

1. 导出 Docker 镜像 ​

构建完成后,将 Docker 镜像导出为 tar 包:

bash
docker save my-cli-agent:latest -o my-cli-agent.tar

注意:不需要使用 gzip 压缩,因为 Docker 导出的 tar 包内部的资源已经经过压缩,再次压缩不会减小体积,反而会浪费 CPU 资源。

2. 提交给管理员 ​

将以下内容提交给 agent-server 平台管理员:

  1. Docker 镜像文件: my-cli-agent.tar
  2. 配置信息:
    yaml
    id: my-cli-agent
    name: "我的CLI Agent"
    proto: MAP-V1
    image: my-cli-agent:latest
    command: node
    args:
      - index.js

3. 管理员安装 ​

管理员执行以下步骤安装 CLI Agent:

  1. 导入 Docker 镜像:

    bash
    docker load -i my-cli-agent.tar
  2. 编辑配置文件: 在 $CWD/etc/docker.yaml 的 builtin_agents 部分添加配置

配置修改后会立即生效,无需重启服务。agent-server 在处理每个请求时都会重新读取 etc/docker.yaml 配置文件。

4. 用户使用 ​

安装完成后,用户刷新前端页面即可在 Agent 列表中看到新安装的 CLI Agent。

最佳实践 ​

1. 日志记录 ​

将重要信息输出到 stderr,便于调试:

javascript
console.error(`[${new Date().toISOString()}] Processing user input: ${userInput}`);

stderr 的内容会被写入日志文件,不会影响 stdout 的消息协议。

2. 错误处理 ​

妥善处理所有可能的错误,并发送 error 消息:

javascript
try {
  await processUserInput(message.userInput);
} catch (error) {
  console.error('Error processing user input:', error);
  sendError(error.message);
  sendMessage('response-state', 'end');
}

3. 资源清理 ​

在 Agent 退出前清理资源:

javascript
process.on('SIGTERM', () => {
  console.error('Received SIGTERM, cleaning up...');
  cleanup();
  process.exit(0);
});

4. 超时处理 ​

agent-server 默认设置了 30 分钟的超时时间。如果你的 Agent 需要更长时间运行,可以考虑:

  • 将任务拆分为多个步骤
  • 实现断点续传机制
  • 在消息中提示用户进度

5. 镜像优化 ​

优化 Docker 镜像大小:

dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install --production && \
    npm cache clean --force

COPY . .

CMD ["node", "index.js"]

调试技巧 ​

本地调试 ​

使用管道模拟 stdin/stdout 通信:

bash
echo '{"type":"user-input","userInput":"测试消息"}' | node index.js

查看日志 ​

查看 Agent 运行日志:

bash
docker logs agent-server

进入容器调试 ​

进入运行中的容器进行调试:

bash
docker exec -it <container-id> sh

常见问题 ​

Q: 如何获取用户的 API Key? ​

A: 用户在首次发送消息时会包含 apiKey 字段。你可以使用这个 API Key 调用 WDS API。

Q: 如何访问沙箱外的文件? ​

A: CLI Agent 只能访问 /data 目录下的沙箱文件。如果需要访问其他资源,需要通过环境变量或配置文件传递。

Q: Agent 运行时间有限制吗? ​

A: agent-server 默认设置了 30 分钟的超时时间。超时后容器会被强制停止。

Q: 如何实现多轮对话? ​

A: 在 Agent 内部维护对话历史,每次收到 user-input 消息时更新历史记录。

相关文档 ​

Happy Coding!