feat(robot_agent): 基于 libhttp 的 DeepSeek 视觉机器人 agent 骨架

- 工具循环:capture_image / get_robot_status / spawn_subagent
- 子代理:技能超集(run_command 带超时截断、read_file / write_file)
- V4L2 摄像头抓帧:MJPEG 优先零转码,YUYV+libjpeg 回退,/dev/video* 自动探测
- 时间戳注入:system prompt 会话级当前时间 + 工具结果执行时间
- CLI:--prompt-file / --camera / --camera-device / --model / --max-turns
This commit is contained in:
qing committed 2026-08-21 14:47:29 +00:00
1 parent 2dc79f0637
commit 7e44025671
12 files changed
+1132

No files matched your search

+1
View File
@@ -0,0 +1 @@
robot_agent
+22
View File
@@ -0,0 +1,22 @@
# robot_agent — 基于 libhttp 的 DeepSeek 视觉机器人 agent 骨架
CXX := g++
CPPFLAGS := --std=c++20 -O2 -Wall -Wextra
LIBHTTP_DIR := ../libhttp
INCS := -I $(LIBHTTP_DIR)/inc
LIBS := -L $(LIBHTTP_DIR)/build -lus_httpclient -lboost_json -lssl -lcrypto -lcurl -ljpeg -lpthread
RPATH := -Wl,-rpath,$(LIBHTTP_DIR)/build
TARGET := robot_agent
SRCS := main.cpp agent.cpp subagent.cpp tools.cpp camera.cpp
all: $(TARGET)
$(TARGET): $(SRCS) tools.hpp
$(CXX) $(CPPFLAGS) $(INCS) $(SRCS) $(LIBS) $(RPATH) -o $@
clean:
rm -f $(TARGET)
.PHONY: all clean
+78
View File
@@ -0,0 +1,78 @@
# robot_agent — 基于 libhttp 的 DeepSeek 视觉机器人 Agent
C++20 机器人智能体骨架:命令行输入任务,调用 DeepSeek 视觉大模型(`deepseek-v4-flash-vision-exp`),通过**工具循环**驱动摄像头识图、状态查询、命令行执行与文件读写,后续可扩展硬件控制。
## 架构
```
用户任务(CLI 参数或 --prompt-file 文件)
└─ 主代理(run_chat_loop 工具循环)
├─ capture_image 拍照识图(真实摄像头 / 模拟图片)
├─ get_robot_status 查询机器人状态
└─ spawn_subagent 委派任务给子代理
└─ 子代理(技能超集,独立工具循环)
├─ run_command 执行 shell 命令(超时 + 输出截断)
├─ read_file / write_file
├─ capture_image / get_robot_status
└─ 返回结论 → 主代理综合回答
```
- **工具循环**:模型返回 `tool_calls` → agent 执行工具 → 结果回传 → 继续,直到模型给出最终回答(`--max-turns` 限轮)。
- **图片注入**:OpenAI 协议的 tool 消息只允许文本,工具返回的图片由主循环作为 `image_url` 注入下一轮。
- **时间戳**:会话开始时向 system prompt 注入一次当前时间(会话内不变);每个工具结果附 `工具执行时间`,供模型判断数据新旧。
- **摄像头**:V4L2 直采,优先 MJPEG(硬件编码零转码,最快),回退 YUYV + libjpeg 编码;设备常开复用;`/dev/video*` 自动探测。
## 依赖与构建
- g++(C++20)、libhttp(`libus_httpclient.so`)、Boost.JSON、libcurl、OpenSSL、libjpeg、pthread
- libhttp 需先编译:`cd ../libhttp && make`
```bash
make # 生成 ./robot_agent
make clean
```
## 使用
```bash
export DEEPSEEK_API_KEY=sk-xxx # 或用 --key
./robot_agent "看看环境里有什么" # 真实摄像头识图
./robot_agent "报告状态" # 状态查询
./robot_agent "列出当前目录文件" # 自动委派子代理执行命令
./robot_agent --prompt-file /tmp/task.txt # 从文件读取任务
./robot_agent "描述图片" --camera ./test.png # 模拟摄像头(读文件,便于离线测试)
```
| 参数 | 说明 | 默认 |
|---|---|---|
| `--prompt-file <文件>` | 从文件读取任务(与位置参数互斥) | — |
| `--camera <文件>` | 模拟摄像头:拍照工具读该图片 | 空 = 真实摄像头 |
| `--camera-device <设备>` | 摄像头设备 | 自动探测 `/dev/video*` |
| `--model <名称>` | 模型名 | `deepseek-v4-flash-vision-exp` |
| `--key <API_KEY>` | API key | 环境变量 `DEEPSEEK_API_KEY` |
| `--endpoint <URL>` | 接口地址 | `https://api.deepseek.com/chat/completions` |
| `--max-turns <N>` | 最大工具轮次 | 8 |
工具日志输出到 stderr(`[主代理][工具]` / `[子代理][工具]`),stdout 只输出最终回答,便于脚本管道。
## 代码结构
| 文件 | 职责 |
|---|---|
| `main.cpp` | CLI 解析、主代理工具注册 |
| `agent.hpp/cpp` | `run_chat_loop` 工具循环(主/子代理共用)、消息构造、时间戳注入 |
| `subagent.hpp/cpp` | 子代理:技能超集 + 独立提示词 |
| `tools.hpp/cpp` | `Tool`/`ToolRegistry` 与全部工具实现 |
| `camera.hpp/cpp` | V4L2 抓帧(MJPEG/YUYV、mmap、超时保护) |
## 扩展
- **加工具**:实现 `Tool{name, description, parameters(JSON Schema), execute}` 后 `registry.add(...)`。
- **工具返回图片**:`ToolResult.image_data_url` 填 data URL,主循环自动注入下一轮。
- **加子代理类型**:仿照 `subagent.cpp` 组装不同工具集与提示词。
- **接真实硬件**:把 `capture_image`/`get_robot_status` 的实现换成设备驱动调用即可,接口不变。
## 安全注意
`run_command` 会执行任意 shell 命令且无白名单/审批,仅建议在可信环境(你自己的机器人主机)运行;后续如需对外暴露,应先加命令审批或沙箱。
+153
View File
@@ -0,0 +1,153 @@
#include "agent.hpp"
#include <us/HttpClient.hpp>
#include <ctime>
#include <iostream>
#include <stdexcept>
using pmc::net::HttpClient;
namespace {
// 本地时间 ISO8601,如 2026-08-21T14:30:05+08:00
std::string now_iso8601() {
std::time_t tt = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
std::tm tm{};
localtime_r(&tt, &tm);
char buf[64];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S%z", &tm);
std::string s(buf);
if (s.size() >= 5 && (s[s.size() - 5] == '+' || s[s.size() - 5] == '-'))
s.insert(s.size() - 2, ":");
return s;
}
} // namespace
json::object user_text_msg(const std::string& text) {
return json::object{{"role", "user"}, {"content", text}};
}
json::object user_image_msg(const std::string& text, const std::string& data_url) {
return json::object{
{"role", "user"},
{"content", json::array{
json::object{{"type", "text"}, {"text", text}},
json::object{{"type", "image_url"},
{"image_url", json::object{{"url", data_url}}}},
}},
};
}
json::object tool_result_msg(const std::string& call_id, const std::string& text) {
return json::object{{"role", "tool"}, {"tool_call_id", call_id}, {"content", text}};
}
json::value build_tools_spec(const ToolRegistry& registry) {
json::array arr;
for (const auto& t : registry.all()) {
arr.push_back(json::object{
{"type", "function"},
{"function", json::object{
{"name", t.name},
{"description", t.description},
{"parameters", t.parameters},
}},
});
}
return arr;
}
std::string run_chat_loop(const AgentConfig& cfg,
const std::string& system_prompt,
const std::string& user_question,
const ToolRegistry& registry,
const std::string& log_tag) {
json::array messages;
// 会话开始注入一次当前时间(会话内保持不变,避免模型混淆"现在")
std::string timed_prompt =
system_prompt + "\n当前时间: " + now_iso8601() + "(本会话内保持不变)";
messages.push_back(json::object{{"role", "system"}, {"content", timed_prompt}});
messages.push_back(user_text_msg(user_question));
HttpClient client;
client.set_header("Authorization: Bearer " + cfg.api_key);
client.set_header("Content-Type: application/json");
for (int turn = 0; turn < cfg.max_turns; ++turn) {
json::object body;
body["model"] = cfg.model;
body["messages"] = messages;
body["tools"] = build_tools_spec(registry);
body["stream"] = false;
std::string response_body;
client.post_stream_sync(cfg.endpoint, json::serialize(body),
[&](const std::string& chunk) { response_body += chunk; });
long code = client.get_response_code();
if (code != 200)
throw std::runtime_error("HTTP " + std::to_string(code) + ": " + response_body);
json::error_code ec;
json::value resp = json::parse(response_body, ec);
if (ec)
throw std::runtime_error("响应不是合法 JSON: " + response_body.substr(0, 200));
if (resp.as_object().contains("error"))
throw std::runtime_error("API 错误: " + json::serialize(resp.at("error")));
auto& choices = resp.at("choices").as_array();
if (choices.empty()) throw std::runtime_error("响应中没有 choices");
auto& message = choices[0].as_object().at("message").as_object();
auto tc_it = message.find("tool_calls");
bool has_tool_calls = tc_it != message.end() && !tc_it->value().is_null() &&
!tc_it->value().as_array().empty();
if (has_tool_calls) {
messages.push_back(message); // 回传 assistant 的 tool_calls 消息
for (const auto& call : tc_it->value().as_array()) {
const auto& c = call.as_object();
std::string call_id =
c.contains("id") ? json::value_to<std::string>(c.at("id")) : "";
const auto& fn = c.at("function").as_object();
std::string name = json::value_to<std::string>(fn.at("name"));
json::value args;
auto args_it = fn.find("arguments");
if (args_it != fn.end() && !args_it->value().is_null()) {
std::string args_str = json::value_to<std::string>(args_it->value());
json::error_code aec;
args = json::parse(args_str, aec);
if (aec) args = json::object();
} else {
args = json::object();
}
ToolResult result;
if (!registry.execute(name, args, result))
result = {"错误: 未知工具 " + name, ""};
// 工具结果统一附执行时间戳,供模型判断数据新旧
std::string timed_result = "工具执行时间: " + now_iso8601() + "\n" + result.text;
std::cerr << "[" << log_tag << "][工具] " << name << " → "
<< timed_result.substr(0, 120) << "\n";
messages.push_back(tool_result_msg(call_id, timed_result));
if (!result.image_data_url.empty())
messages.push_back(
user_image_msg("工具返回的图片如下,请结合图片继续回答。",
result.image_data_url));
}
continue;
}
if (message.contains("content") && message.at("content").is_string())
return json::value_to<std::string>(message.at("content"));
throw std::runtime_error("模型没有返回文本内容");
}
throw std::runtime_error("达到最大工具轮次 (" + std::to_string(cfg.max_turns) +
"),未获得最终回答");
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <boost/json.hpp>
#include "tools.hpp"
#include <string>
namespace json = boost::json;
struct AgentConfig {
std::string model;
std::string endpoint;
std::string api_key;
std::string camera_path; // 非空 = 模拟摄像头(读文件)
std::string camera_device; // 真实摄像头设备(默认 /dev/video0)
int max_turns = 8;
};
// 运行工具循环,直到模型给出最终回答并返回其文本。失败抛 std::runtime_error。
// log_tag 用于区分不同代理的工具日志(输出到 stderr)。
std::string run_chat_loop(const AgentConfig& cfg,
const std::string& system_prompt,
const std::string& user_question,
const ToolRegistry& registry,
const std::string& log_tag);
// 消息构造(主代理与子代理共用)
json::object user_text_msg(const std::string& text);
json::object user_image_msg(const std::string& text, const std::string& data_url);
json::object tool_result_msg(const std::string& call_id, const std::string& text);
json::value build_tools_spec(const ToolRegistry& registry);
+249
View File
@@ -0,0 +1,249 @@
#include "camera.hpp"
#include <jpeglib.h>
#include <fcntl.h>
#include <glob.h>
#include <linux/videodev2.h>
#include <poll.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <algorithm>
#include <chrono>
#include <cstring>
#include <stdexcept>
namespace {
constexpr size_t CAPTURE_BUFFER_COUNT = 3;
constexpr int FRAME_TIMEOUT_MS = 1500; // 单帧出队超时
// 扫描 /dev/video*,返回第一个支持视频采集的设备
std::string auto_detect_device() {
glob_t g;
std::vector<std::string> candidates;
if (glob("/dev/video*", 0, nullptr, &g) == 0) {
for (size_t i = 0; i < g.gl_pathc; ++i)
candidates.emplace_back(g.gl_pathv[i]);
}
globfree(&g);
std::sort(candidates.begin(), candidates.end());
for (const auto& dev : candidates) {
int fd = open(dev.c_str(), O_RDWR | O_NONBLOCK);
if (fd < 0) continue;
v4l2_capability cap{};
bool ok = ioctl(fd, VIDIOC_QUERYCAP, &cap) == 0 &&
(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE);
close(fd);
if (ok) return dev;
}
throw std::runtime_error("未找到可用的视频采集设备(已扫描 /dev/video*,"
"可用 --camera-device 指定)");
}
void xioctl(int fd, unsigned long request, void* arg, const char* what) {
if (ioctl(fd, request, arg) != 0)
throw std::runtime_error(std::string(what) + " 失败: " + strerror(errno));
}
// YUYV → RGB24 → JPEG(libjpeg 内存压缩)
std::vector<unsigned char> yuyv_to_jpeg(const std::vector<unsigned char>& yuyv,
unsigned w, unsigned h) {
std::vector<unsigned char> rgb(static_cast<size_t>(w) * h * 3);
const unsigned char* in = yuyv.data();
unsigned char* out = rgb.data();
for (unsigned y = 0; y < h; ++y) {
for (unsigned x = 0; x + 1 < w; x += 2) {
int y0 = in[0], u = in[1] - 128, y1 = in[2], v = in[3] - 128;
in += 4;
int c0 = y0 - 16, c1 = y1 - 16;
int r0 = (298 * c0 + 409 * v + 128) >> 8;
int g0 = (298 * c0 - 100 * u - 208 * v + 128) >> 8;
int b0 = (298 * c0 + 516 * u + 128) >> 8;
int r1 = (298 * c1 + 409 * v + 128) >> 8;
int g1 = (298 * c1 - 100 * u - 208 * v + 128) >> 8;
int b1 = (298 * c1 + 516 * u + 128) >> 8;
*out++ = static_cast<unsigned char>(std::clamp(r0, 0, 255));
*out++ = static_cast<unsigned char>(std::clamp(g0, 0, 255));
*out++ = static_cast<unsigned char>(std::clamp(b0, 0, 255));
*out++ = static_cast<unsigned char>(std::clamp(r1, 0, 255));
*out++ = static_cast<unsigned char>(std::clamp(g1, 0, 255));
*out++ = static_cast<unsigned char>(std::clamp(b1, 0, 255));
}
if (w % 2) { // 奇数宽度:最后一像素复用前一对的 U/V
int y0 = in[0], u = in[1] - 128, v = in[2] - 128;
in += 4;
int c0 = y0 - 16;
*out++ = static_cast<unsigned char>(std::clamp((298 * c0 + 409 * v + 128) >> 8, 0, 255));
*out++ = static_cast<unsigned char>(std::clamp((298 * c0 - 100 * u - 208 * v + 128) >> 8, 0, 255));
*out++ = static_cast<unsigned char>(std::clamp((298 * c0 + 516 * u + 128) >> 8, 0, 255));
}
}
jpeg_compress_struct cinfo;
jpeg_error_mgr jerr;
cinfo.err = jpeg_std_error(&jerr);
jpeg_create_compress(&cinfo);
unsigned char* outbuf = nullptr;
unsigned long outsize = 0;
jpeg_mem_dest(&cinfo, &outbuf, &outsize);
cinfo.image_width = w;
cinfo.image_height = h;
cinfo.input_components = 3;
cinfo.in_color_space = JCS_RGB;
jpeg_set_defaults(&cinfo);
jpeg_set_quality(&cinfo, 85, TRUE);
jpeg_start_compress(&cinfo, TRUE);
while (cinfo.next_scanline < h) {
JSAMPROW row = rgb.data() + static_cast<size_t>(cinfo.next_scanline) * w * 3;
jpeg_write_scanlines(&cinfo, &row, 1);
}
jpeg_finish_compress(&cinfo);
std::vector<unsigned char> result(outbuf, outbuf + outsize);
free(outbuf);
jpeg_destroy_compress(&cinfo);
return result;
}
} // namespace
CameraCapture::CameraCapture(const std::string& device) : device_(device) {
if (device_.empty()) device_ = auto_detect_device();
open_device();
init_format();
request_buffers();
}
CameraCapture::~CameraCapture() {
std::lock_guard<std::mutex> lock(mtx_);
if (fd_ < 0) return;
try {
stream_off();
} catch (...) {
}
for (size_t i = 0; i < buf_count_; ++i)
munmap(buffers_[i], buffer_sizes_[i]);
close(fd_);
}
void CameraCapture::open_device() {
fd_ = open(device_.c_str(), O_RDWR | O_NONBLOCK);
if (fd_ < 0)
throw std::runtime_error("无法打开摄像头 " + device_ + ": " + strerror(errno));
v4l2_capability cap{};
if (ioctl(fd_, VIDIOC_QUERYCAP, &cap) != 0)
throw std::runtime_error("VIDIOC_QUERYCAP 失败: " + std::string(strerror(errno)));
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE) ||
!(cap.capabilities & V4L2_CAP_STREAMING))
throw std::runtime_error(device_ + " 不支持视频捕获或 mmap 流式传输");
}
void CameraCapture::init_format() {
v4l2_format fmt{};
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
fmt.fmt.pix.width = 640;
fmt.fmt.pix.height = 480;
fmt.fmt.pix.field = V4L2_FIELD_ANY;
// 优先 MJPEG:摄像头硬件编码,抓帧即用,无需转码
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_MJPEG;
if (ioctl(fd_, VIDIOC_S_FMT, &fmt) == 0 && fmt.fmt.pix.pixelformat == V4L2_PIX_FMT_MJPEG) {
jpeg_format_ = true;
} else {
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV;
xioctl(fd_, VIDIOC_S_FMT, &fmt, "设置 YUYV 格式");
if (fmt.fmt.pix.pixelformat != V4L2_PIX_FMT_YUYV)
throw std::runtime_error(device_ + " 不支持 MJPEG 或 YUYV");
jpeg_format_ = false;
}
width_ = fmt.fmt.pix.width;
height_ = fmt.fmt.pix.height;
}
void CameraCapture::request_buffers() {
v4l2_requestbuffers req{};
req.count = CAPTURE_BUFFER_COUNT;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;
xioctl(fd_, VIDIOC_REQBUFS, &req, "申请缓冲");
if (req.count < 2)
throw std::runtime_error("摄像头缓冲不足");
buf_count_ = req.count;
buffers_.resize(buf_count_);
buffer_sizes_.resize(buf_count_);
for (size_t i = 0; i < buf_count_; ++i) {
v4l2_buffer buf{};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = i;
xioctl(fd_, VIDIOC_QUERYBUF, &buf, "查询缓冲");
buffers_[i] = mmap(nullptr, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd_,
buf.m.offset);
if (buffers_[i] == MAP_FAILED)
throw std::runtime_error("mmap 失败: " + std::string(strerror(errno)));
buffer_sizes_[i] = buf.length;
xioctl(fd_, VIDIOC_QBUF, &buf, "缓冲入队");
}
}
void CameraCapture::stream_on() {
int type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
xioctl(fd_, VIDIOC_STREAMON, &type, "开启视频流");
}
void CameraCapture::stream_off() {
int type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (ioctl(fd_, VIDIOC_STREAMOFF, &type) != 0 && errno != EINVAL)
throw std::runtime_error("关闭视频流失败: " + std::string(strerror(errno)));
}
std::vector<unsigned char> CameraCapture::dequeue_frame(int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
while (true) {
v4l2_buffer buf{};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
if (ioctl(fd_, VIDIOC_DQBUF, &buf) == 0) {
std::vector<unsigned char> frame(static_cast<unsigned char*>(buffers_[buf.index]),
static_cast<unsigned char*>(buffers_[buf.index]) +
buf.bytesused);
xioctl(fd_, VIDIOC_QBUF, &buf, "缓冲重新入队");
return frame;
}
if (errno != EAGAIN)
throw std::runtime_error("出队帧失败: " + std::string(strerror(errno)));
auto now = std::chrono::steady_clock::now();
if (now >= deadline)
throw std::runtime_error("等待摄像头画面超时");
int remain_ms = static_cast<int>(
std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count());
struct pollfd pfd{fd_, POLLIN, 0};
poll(&pfd, 1, std::min(remain_ms, 200));
}
}
std::string CameraCapture::grab_jpeg() {
std::lock_guard<std::mutex> lock(mtx_);
stream_on();
try {
dequeue_frame(FRAME_TIMEOUT_MS); // 丢弃第一帧(曝光未稳定)
auto frame = dequeue_frame(FRAME_TIMEOUT_MS); // 取第二帧
stream_off();
if (!jpeg_format_) {
auto jpeg = yuyv_to_jpeg(frame, width_, height_);
return std::string(reinterpret_cast<char*>(jpeg.data()), jpeg.size());
}
return std::string(reinterpret_cast<char*>(frame.data()), frame.size());
} catch (...) {
try {
stream_off();
} catch (...) {
}
throw;
}
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <cstddef>
#include <mutex>
#include <string>
#include <vector>
// V4L2 摄像头抓帧:优先 MJPEG(硬件编码,抓到的就是 JPEG,零转码,速度最快),
// 设备不支持时回退 YUYV + libjpeg 软件编码。
// 设备常开复用(构造时打开并建流),每次抓帧 = STREAMON → 取帧 → STREAMOFF。
// device 为空时自动扫描 /dev/video* 探测第一个可用设备。失败抛 std::runtime_error。
class CameraCapture {
public:
explicit CameraCapture(const std::string& device);
~CameraCapture();
CameraCapture(const CameraCapture&) = delete;
CameraCapture& operator=(const CameraCapture&) = delete;
// 抓一帧,返回 JPEG 数据
std::string grab_jpeg();
unsigned width() const { return width_; }
unsigned height() const { return height_; }
private:
void open_device();
void init_format();
void request_buffers();
void stream_on();
void stream_off();
// 出队一帧(带超时)。skip_first=true 时丢弃第一帧再取第二帧(等待曝光稳定)
std::vector<unsigned char> dequeue_frame(int timeout_ms);
std::string device_;
int fd_ = -1;
bool jpeg_format_ = false; // true = MJPEG 直出,false = YUYV 需转码
unsigned width_ = 0;
unsigned height_ = 0;
std::vector<void*> buffers_;
std::vector<size_t> buffer_sizes_;
size_t buf_count_ = 0;
std::mutex mtx_;
};
+129
View File
@@ -0,0 +1,129 @@
#include "agent.hpp"
#include "tools.hpp"
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
namespace {
constexpr const char* DEFAULT_ENDPOINT = "https://api.deepseek.com/chat/completions";
constexpr const char* DEFAULT_MODEL = "deepseek-v4-flash-vision-exp";
constexpr const char* MAIN_SYSTEM_PROMPT =
"你是控制硬件机器人的主智能体。当用户的问题涉及观察环境、看东西、识别物体时,"
"必须先调用 capture_image 工具获取照片再回答。"
"当任务需要执行 shell 命令、读写文件等操作时,调用 spawn_subagent 工具委派给子代理,"
"不要自己尝试执行。回答使用中文。";
struct Options {
std::string question;
std::string prompt_file; // 非空 = 从文件读取问题
std::string camera_path; // 非空 = 模拟摄像头(读文件)
std::string camera_device; // 空 = 自动探测 /dev/video*
std::string model = DEFAULT_MODEL;
std::string endpoint = DEFAULT_ENDPOINT;
std::string api_key;
int max_turns = 8;
};
void print_usage(const char* prog) {
std::cerr << "用法: " << prog << " \"问题\" [选项]\n"
<< " " << prog << " --prompt-file <文件> [选项]\n"
<< "选项:\n"
<< " --prompt-file <文件> 从文件读取问题(与位置参数互斥)\n"
<< " --camera <文件> 模拟摄像头:拍照工具读该图片(不指定则用真实摄像头)\n"
<< " --camera-device <设备> 摄像头设备(默认自动探测 /dev/video*)\n"
<< " --model <名称> 模型名(默认 " << DEFAULT_MODEL << ")\n"
<< " --key <API_KEY> API key(默认读环境变量 DEEPSEEK_API_KEY)\n"
<< " --endpoint <URL> 接口地址(默认 DeepSeek 官方)\n"
<< " --max-turns <N> 最大工具轮次(默认 8)\n";
}
Options parse_args(int argc, char** argv) {
Options opt;
if (const char* key_env = std::getenv("DEEPSEEK_API_KEY")) opt.api_key = key_env;
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
auto next = [&](const char* name) -> std::string {
if (i + 1 >= argc) {
std::cerr << "错误: " << name << " 缺少参数\n";
std::exit(2);
}
return argv[++i];
};
if (arg == "--camera") opt.camera_path = next("--camera");
else if (arg == "--camera-device") opt.camera_device = next("--camera-device");
else if (arg == "--prompt-file") opt.prompt_file = next("--prompt-file");
else if (arg == "--model") opt.model = next("--model");
else if (arg == "--key") opt.api_key = next("--key");
else if (arg == "--endpoint") opt.endpoint = next("--endpoint");
else if (arg == "--max-turns") opt.max_turns = std::stoi(next("--max-turns"));
else if (arg == "--help" || arg == "-h") { print_usage(argv[0]); std::exit(0); }
else if (!arg.empty() && arg[0] == '-') {
std::cerr << "错误: 未知选项 " << arg << "\n";
print_usage(argv[0]);
std::exit(2);
} else {
opt.question = arg;
}
}
return opt;
}
} // namespace
int main(int argc, char** argv) {
Options opt = parse_args(argc, argv);
if (!opt.prompt_file.empty()) {
if (!opt.question.empty()) {
std::cerr << "错误: --prompt-file 与位置参数问题不能同时使用\n";
return 2;
}
std::ifstream f(opt.prompt_file);
if (!f) {
std::cerr << "错误: 无法读取提示词文件 " << opt.prompt_file << "\n";
return 2;
}
std::ostringstream ss;
ss << f.rdbuf();
opt.question = ss.str();
}
if (opt.question.empty()) {
std::cerr << "错误: 缺少问题参数(用位置参数或 --prompt-file)\n";
print_usage(argv[0]);
return 2;
}
if (opt.api_key.empty()) {
std::cerr << "错误: 缺少 API key(用 --key 或设置环境变量 DEEPSEEK_API_KEY)\n";
return 2;
}
AgentConfig cfg;
cfg.model = opt.model;
cfg.endpoint = opt.endpoint;
cfg.api_key = opt.api_key;
cfg.camera_path = opt.camera_path;
cfg.camera_device = opt.camera_device;
cfg.max_turns = opt.max_turns;
ToolRegistry registry;
registry.add(make_capture_image_tool(opt.camera_path, opt.camera_device));
registry.add(make_robot_status_tool());
registry.add(make_spawn_subagent_tool(cfg));
try {
std::string answer =
run_chat_loop(cfg, MAIN_SYSTEM_PROMPT, opt.question, registry, "主代理");
std::cout << answer << "\n";
} catch (const std::exception& e) {
std::cerr << "错误: " << e.what() << "\n";
return 1;
}
return 0;
}
+23
View File
@@ -0,0 +1,23 @@
#include "subagent.hpp"
#include "tools.hpp"
namespace {
constexpr const char* SUBAGENT_PROMPT =
"你是机器人的子代理(专家),拥有比主代理更丰富的技能:可以执行 shell 命令、"
"读写文件、拍照和查询状态。请利用这些技能完成主代理委派的任务,"
"返回简洁明确的结果,用中文。";
} // namespace
std::string run_subagent(const AgentConfig& cfg, const std::string& task) {
ToolRegistry registry;
registry.add(make_run_command_tool());
registry.add(make_read_file_tool());
registry.add(make_write_file_tool());
registry.add(make_capture_image_tool(cfg.camera_path, cfg.camera_device));
registry.add(make_robot_status_tool());
return run_chat_loop(cfg, SUBAGENT_PROMPT, task, registry, "子代理");
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include "agent.hpp"
#include <string>
// 子代理:拥有命令行执行、文件读写等扩展技能(主代理的超集),
// 完成主代理委派的任务后返回结论文本。
std::string run_subagent(const AgentConfig& cfg, const std::string& task);
+329
View File
@@ -0,0 +1,329 @@
#include "tools.hpp"
#include "agent.hpp"
#include "camera.hpp"
#include "subagent.hpp"
#include <array>
#include <cerrno>
#include <chrono>
#include <csignal>
#include <fcntl.h>
#include <fstream>
#include <memory>
#include <poll.h>
#include <sstream>
#include <stdexcept>
#include <sys/wait.h>
#include <unistd.h>
namespace {
std::string base64_encode(const std::string& in) {
static const char table[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve((in.size() + 2) / 3 * 4);
for (size_t i = 0; i < in.size(); i += 3) {
unsigned n = static_cast<unsigned char>(in[i]) << 16;
if (i + 1 < in.size()) n |= static_cast<unsigned char>(in[i + 1]) << 8;
if (i + 2 < in.size()) n |= static_cast<unsigned char>(in[i + 2]);
out.push_back(table[(n >> 18) & 0x3F]);
out.push_back(table[(n >> 12) & 0x3F]);
out.push_back(i + 1 < in.size() ? table[(n >> 6) & 0x3F] : '=');
out.push_back(i + 2 < in.size() ? table[n & 0x3F] : '=');
}
return out;
}
std::string mime_from_path(const std::string& path) {
auto pos = path.find_last_of('.');
std::string ext = (pos == std::string::npos) ? "" : path.substr(pos + 1);
if (ext == "jpg" || ext == "jpeg") return "image/jpeg";
if (ext == "png") return "image/png";
if (ext == "webp") return "image/webp";
if (ext == "gif") return "image/gif";
throw std::runtime_error("不支持的图片格式: " + ext);
}
} // namespace
Tool make_capture_image_tool(const std::string& simulated_image_path,
const std::string& camera_device) {
Tool t;
t.name = "capture_image";
t.description =
"拍摄一张机器人当前环境的照片。当用户的问题涉及观察、看、识别物体或了解周围环境时,"
"必须先调用本工具获取照片,再基于照片回答。";
t.parameters = json::object{{"type", "object"}, {"properties", json::object{}}};
// 摄像头实例懒加载并常开复用,避免每次抓帧都重新打开设备
auto camera = std::make_shared<std::shared_ptr<CameraCapture>>();
t.execute = [simulated_image_path, camera_device, camera](const json::value&) -> ToolResult {
if (!simulated_image_path.empty()) {
std::ifstream f(simulated_image_path, std::ios::binary);
if (!f)
return {"错误: 无法读取模拟摄像头图片 " + simulated_image_path, ""};
std::ostringstream ss;
ss << f.rdbuf();
std::string data_url;
try {
data_url = "data:" + mime_from_path(simulated_image_path) + ";base64," +
base64_encode(ss.str());
} catch (const std::exception& e) {
return {std::string("错误: ") + e.what(), ""};
}
return {"已拍摄当前环境照片", data_url};
}
try {
if (!*camera)
*camera = std::make_shared<CameraCapture>(camera_device);
std::string jpeg = (*camera)->grab_jpeg();
return {"已拍摄当前环境照片(" + std::to_string((*camera)->width()) + "x" +
std::to_string((*camera)->height()) + ")",
"data:image/jpeg;base64," + base64_encode(jpeg)};
} catch (const std::exception& e) {
camera->reset(); // 设备可能重新插拔,下次调用重试打开
return {std::string("摄像头抓拍失败: ") + e.what(), ""};
}
};
return t;
}
Tool make_robot_status_tool() {
Tool t;
t.name = "get_robot_status";
t.description = "查询机器人当前状态(电量、温度、电机、传感器)。用户询问机器人状态时调用。";
t.parameters = json::object{{"type", "object"}, {"properties", json::object{}}};
t.execute = [](const json::value&) -> ToolResult {
return {"电量 85%,电机正常,温度 42°C,传感器无告警", ""};
};
return t;
}
// ---- 命令行执行 ----
constexpr size_t MAX_STREAM_BYTES = 64 * 1024; // 单个流输出上限
constexpr int MAX_CMD_TIMEOUT_S = 60;
struct RunResult {
int exit_code = -1;
bool timed_out = false;
bool truncated = false;
std::string out;
std::string err;
};
// 追加输出,超出上限则丢弃并标记截断
void append_capped(std::string& dst, const char* p, size_t n, bool& truncated) {
size_t room = MAX_STREAM_BYTES - dst.size();
if (n > room) {
dst.append(p, room);
truncated = true;
} else {
dst.append(p, n);
}
}
RunResult run_shell(const std::string& cmd, int timeout_s) {
int po[2], pe[2];
if (pipe(po) != 0 || pipe(pe) != 0) throw std::runtime_error("pipe 失败");
pid_t pid = fork();
if (pid < 0) throw std::runtime_error("fork 失败");
if (pid == 0) {
dup2(po[1], STDOUT_FILENO);
dup2(pe[1], STDERR_FILENO);
close(po[0]);
close(po[1]);
close(pe[0]);
close(pe[1]);
execl("/bin/sh", "sh", "-c", cmd.c_str(), static_cast<char*>(nullptr));
_exit(127);
}
close(po[1]);
close(pe[1]);
fcntl(po[0], F_SETFL, O_NONBLOCK);
fcntl(pe[0], F_SETFL, O_NONBLOCK);
RunResult r;
bool exited = false, out_eof = false, err_eof = false, killed = false;
int status = 0;
std::array<char, 4096> buf;
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_s);
auto hard_deadline = deadline + std::chrono::seconds(3);
while (!(exited && out_eof && err_eof)) {
struct pollfd fds[2] = {
{out_eof ? -1 : po[0], POLLIN, 0},
{err_eof ? -1 : pe[0], POLLIN, 0},
};
if (poll(fds, 2, 100) > 0) {
for (const auto& fd : fds) {
if (fd.fd < 0 || !(fd.revents & (POLLIN | POLLHUP))) continue;
std::string& sink = (fd.fd == po[0]) ? r.out : r.err;
ssize_t n;
while ((n = read(fd.fd, buf.data(), buf.size())) > 0)
append_capped(sink, buf.data(), n, r.truncated);
if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) {
close(fd.fd);
if (fd.fd == po[0]) out_eof = true;
else err_eof = true;
}
}
}
if (!exited) {
pid_t w = waitpid(pid, &status, WNOHANG);
if (w == pid) exited = true;
}
auto now = std::chrono::steady_clock::now();
if (!killed && now >= deadline) {
kill(pid, SIGKILL);
killed = true;
r.timed_out = true;
}
if (now >= hard_deadline) break;
}
if (!out_eof) close(po[0]);
if (!err_eof) close(pe[0]);
if (!exited) waitpid(pid, &status, 0); // 已被 SIGKILL,等待回收
r.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
return r;
}
std::string format_run_result(const RunResult& r) {
std::string s = "exit_code=" + std::to_string(r.exit_code);
if (r.timed_out) s += "(超时,已终止)";
s += "\nstdout:\n" + (r.out.empty() ? "(空)" : r.out);
s += "\nstderr:\n" + (r.err.empty() ? "(空)" : r.err);
if (r.truncated) s += "\n[输出超过 64KB,已截断]";
return s;
}
Tool make_run_command_tool() {
Tool t;
t.name = "run_command";
t.description =
"在机器人主机上执行 shell 命令,返回退出码、标准输出和标准错误。"
"可用于查看系统状态、操作硬件等。命令有超时限制,输出超过 64KB 会被截断。";
t.parameters = json::object{
{"type", "object"},
{"properties", json::object{
{"command", json::object{{"type", "string"}, {"description", "要执行的 shell 命令"}}},
{"timeout_seconds", json::object{{"type", "integer"},
{"description", "超时秒数,默认 10,最大 60"}}},
}},
{"required", json::array{"command"}},
};
t.execute = [](const json::value& args) -> ToolResult {
const auto& obj = args.as_object();
auto cit = obj.find("command");
if (cit == obj.end() || !cit->value().is_string())
return {"错误: 缺少 command 参数", ""};
std::string cmd = json::value_to<std::string>(cit->value());
int timeout_s = 10;
auto tit = obj.find("timeout_seconds");
if (tit != obj.end() && tit->value().is_int64())
timeout_s = static_cast<int>(tit->value().as_int64());
if (timeout_s < 1) timeout_s = 1;
if (timeout_s > MAX_CMD_TIMEOUT_S) timeout_s = MAX_CMD_TIMEOUT_S;
try {
return {format_run_result(run_shell(cmd, timeout_s)), ""};
} catch (const std::exception& e) {
return {std::string("执行失败: ") + e.what(), ""};
}
};
return t;
}
// ---- 文件读写 ----
Tool make_read_file_tool() {
Tool t;
t.name = "read_file";
t.description = "读取文本文件内容。超过 64KB 截断。";
t.parameters = json::object{
{"type", "object"},
{"properties", json::object{
{"path", json::object{{"type", "string"}, {"description", "文件路径"}}},
}},
{"required", json::array{"path"}},
};
t.execute = [](const json::value& args) -> ToolResult {
auto it = args.as_object().find("path");
if (it == args.as_object().end() || !it->value().is_string())
return {"错误: 缺少 path 参数", ""};
std::string path = json::value_to<std::string>(it->value());
std::ifstream f(path, std::ios::binary);
if (!f) return {"错误: 无法读取文件 " + path, ""};
std::string content((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
bool truncated = content.size() > MAX_STREAM_BYTES;
if (truncated) content.resize(MAX_STREAM_BYTES);
return {content + (truncated ? "\n[内容已截断]" : ""), ""};
};
return t;
}
Tool make_write_file_tool() {
Tool t;
t.name = "write_file";
t.description = "写入文本文件(覆盖已有内容)。";
t.parameters = json::object{
{"type", "object"},
{"properties", json::object{
{"path", json::object{{"type", "string"}, {"description", "文件路径"}}},
{"content", json::object{{"type", "string"}, {"description", "要写入的内容"}}},
}},
{"required", json::array{"path", "content"}},
};
t.execute = [](const json::value& args) -> ToolResult {
const auto& obj = args.as_object();
auto pit = obj.find("path");
auto cit = obj.find("content");
if (pit == obj.end() || !pit->value().is_string() || cit == obj.end() ||
!cit->value().is_string())
return {"错误: 需要 path 和 content 参数", ""};
std::string path = json::value_to<std::string>(pit->value());
std::string content = json::value_to<std::string>(cit->value());
std::ofstream f(path, std::ios::binary);
if (!f) return {"错误: 无法写入文件 " + path, ""};
f.write(content.data(), static_cast<std::streamsize>(content.size()));
if (!f) return {"错误: 写入失败 " + path, ""};
return {"已写入 " + std::to_string(content.size()) + " 字节到 " + path, ""};
};
return t;
}
// ---- 子代理 ----
Tool make_spawn_subagent_tool(const AgentConfig& cfg) {
Tool t;
t.name = "spawn_subagent";
t.description =
"委派任务给子代理执行。子代理拥有扩展技能:执行 shell 命令、读写文件、拍照、"
"查询状态。当任务需要运行命令、读写文件等主代理不具备的能力时调用。";
t.parameters = json::object{
{"type", "object"},
{"properties", json::object{
{"task", json::object{{"type", "string"}, {"description", "委派给子代理的具体任务"}}},
}},
{"required", json::array{"task"}},
};
t.execute = [cfg](const json::value& args) -> ToolResult {
auto it = args.as_object().find("task");
if (it == args.as_object().end() || !it->value().is_string())
return {"错误: 缺少 task 参数", ""};
std::string task = json::value_to<std::string>(it->value());
try {
return {run_subagent(cfg, task), ""};
} catch (const std::exception& e) {
return {std::string("子代理执行失败: ") + e.what(), ""};
}
};
return t;
}
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <boost/json.hpp>
#include <functional>
#include <string>
#include <vector>
namespace json = boost::json;
struct AgentConfig; // 前向声明(定义见 agent.hpp),避免头文件循环依赖
// 工具执行结果。image_data_url 非空表示工具返回了一张图片,
// 由 agent 主循环把图片注入下一轮对话(OpenAI 协议的 tool 消息只允许文本)。
struct ToolResult {
std::string text;
std::string image_data_url;
};
struct Tool {
std::string name;
std::string description;
json::value parameters; // JSON Schema(object)
std::function<ToolResult(const json::value& args)> execute;
};
class ToolRegistry {
public:
void add(Tool tool) { tools_.push_back(std::move(tool)); }
const std::vector<Tool>& all() const { return tools_; }
bool execute(const std::string& name, const json::value& args, ToolResult& out) const {
for (const auto& t : tools_) {
if (t.name == name) {
out = t.execute(args);
return true;
}
}
return false;
}
private:
std::vector<Tool> tools_;
};
// 拍照工具:simulated_image_path 非空时读取该文件(模拟模式),
// 否则从真实摄像头设备(V4L2,默认 /dev/video0)抓帧。
Tool make_capture_image_tool(const std::string& simulated_image_path,
const std::string& camera_device);
// 状态查询工具:返回模拟的机器人状态。
Tool make_robot_status_tool();
// 命令行工具:执行 shell 命令(带超时与输出截断)。
Tool make_run_command_tool();
// 文件工具:读取/写入文本文件。
Tool make_read_file_tool();
Tool make_write_file_tool();
// 子代理工具:把任务委派给拥有扩展技能的子代理执行。
Tool make_spawn_subagent_tool(const AgentConfig& cfg);