Files
qing 7e44025671 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
2026-08-21 14:47:29 +00:00

330 lines
12 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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;
}