1.1-main.cpp入口解剖
源码仓库:
- 主仓库:gitee.com/chen_dl/ai-...
- A2A 协议库:gitee.com/chen_dl/a2a...
- MCP 扩展平台:gitee.com/chen_dl/mcp...
上一篇把三条命令拆开,最后落到一句建议:读代码从 src/main.cpp 开始。这篇就顺着这句话走进去。src/main.cpp 有 607 行,但里面几乎没有算法,全部内容是把配置、模型、运行时、工具、A2A 服务端和 Web 网关按正确的顺序创建出来,再用一组回调接在一起。这篇要回答三个问题:创建顺序为什么是这样、模块之间的依赖指向哪、每个 lambda 到底接住了哪条请求路径。
客户端这边先交代一句:项目的主线是终端 coding agent (de / cli/),它通过 WebGateway 的 HTTP/SSE 接口工作;web/node_frontend 那个浏览器前端只是当初用来验证链路的可选页面,不是重点。所以下面会把「coding agent 走的那条流式路径」当主线讲,浏览器入口只在必要时提一句。
1. 问题背景:入口文件为什么值得单独读
C++ 没有 Spring 那样现成的依赖注入容器,一个服务的「接线」是手写的。写的人不觉得难,读的人却常常卡住:变量太多、顺序有讲究、某些失败要直接退出,某些失败却可以降级。如果只看 AgentRuntime 或 DeepSeekClient 的实现,很容易漏掉它们是怎么被创建、被谁持有的。
main.cpp 就是回答这些问题的唯一位置。它实际上承担了四件事:
- 解析启动参数:环境变量与命令行的优先级、默认端口和默认 db 路径。
- 装载五段配置:DeepSeek、日志、MCP、压缩、项目指令,每段缺省行为不同。
- 构造并注入组件 :谁先创建、谁持有谁、哪些是栈对象、哪些是
shared_ptr。 - 注册回调 :把 A2A 的
SendMessage和 coding agent 走的流式接口接到同一个AgentRuntime。
难点不在某一行的写法,而在这四件事交织时的取舍。比如「DeepSeek 配置缺失就直接退出,MCP 加载失败却继续跑」,这类决定只写在 main.cpp 里,不读它就无从知道。另外,随着会话管理、压缩、项目指令这些功能加进来,入口文件已经积累了十来个回调,共 607 行,这也是一种需要正视的工程现状。
2. 整体结构:main() 的七个阶段
main() 可以按执行顺序切成七个阶段,前一阶段的产物是后一阶段的输入:
有两个细节值得先点出来:
- 配置在前,日志紧随其后。因为后面每一段初始化都可能写日志,日志系统必须先在配置阶段末期就绪。
- 两个网络服务最后才启动。所有回调在主线程启动服务之前就注册完毕,因此请求进来时依赖已经全部就位,不需要为「服务已监听但回调还没设」做额外同步。
下面按阶段拆开。
3. 源码精读
3.1 选项解析:环境变量兜底,命令行覆盖
启动参数分成两层。AppOptions 先给一组默认值,ParseOptions() 先用环境变量覆盖默认值,再用命令行参数覆盖环境变量:
ini
struct AppOptions {
int port = 18080;
int web_port = 18081;
std::string config_path = "conf/conf.ini";
std::string db_path = "build/db/aiagent_tasks.db";
std::string mcp_binary;
// ... 日志、MCP 插件目录等
};
AppOptions ParseOptions(int argc, char** argv) {
AppOptions options;
if (const char* value = std::getenv("AIAGENT_PORT")) {
options.port = std::stoi(value);
}
// ... AIAGENT_WEB_PORT / AIAGENT_CONFIG / AIAGENT_DB 同理
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "--config" && i + 1 < argc) {
options.config_path = argv[++i];
}
// ... --db / --port / --web-port / --log-file 等
else {
options.port = std::stoi(arg); // 兼容直接传端口的历史用法
}
}
return options;
}
它解决的问题是同一份二进制在不同环境下改端口/路径 :容器里用 AIAGENT_PORT 注入,本地调试用 --port 覆盖。最后一个 else 分支把无法识别的裸参数当成端口,这是早期版本的调用方式,保留下来避免破坏已有脚本。
代价是这里没有 --help,也没有对 std::stoi 失败的保护------参数不是合法整数时会抛异常,被最外层的 catch 接到,打印一条致命日志后退出。对内部工具够用,但如果要做成对外分发的命令行程序,这里迟早要补参数校验和帮助信息。
3.2 五段配置:哪些必须、哪些可缺省
配置从同一个 conf.ini 分出五段,由五个独立的 Load*ConfigFile() 装载。它们的严格程度不一样:
| INI 段 | 装载函数 | 缺失/失败时的行为 |
|---|---|---|
[deepseek] |
LoadDeepSeekConfigFile |
直接打印 配置错误 并 return 1 |
[logging] |
LoadLogConfigFile |
用默认值,之后可被 --log-* 覆盖 |
[mcp] |
LoadMCPConfigFile |
没写就尝试自动发现 vendor 下的本地构建 |
[compaction] |
LoadCompactionConfigFile |
用默认值(enabled=true,窗口 64000) |
[instructions] |
LoadInstructionsConfigFile |
解析失败会打印 配置错误 并退出(无该段时用默认值) |
只有 DeepSeek 是硬门槛:
arduino
const auto config = aiagent::LoadDeepSeekConfigFile(options.config_path, &config_error);
if (!config.has_value()) {
std::cerr << "配置错误: " << config_error << '\n';
return 1;
}
原因很直接:没有 API Key 和模型名,整个服务没有任何可用的对话能力,继续跑只会让每个请求都失败,不如启动时就报清楚。.env / DEEPSEEK_API_KEY 的回退逻辑封装在 LoadDeepSeekConfigFile 里,入口这里不用关心。
日志的装载顺序是:先读 [logging],再用命令行参数逐个覆盖,最后一次性配置全局 Logger:
ini
aiagent::LogConfig log_config;
if (const auto parsed_log = aiagent::LoadLogConfigFile(options.config_path, &log_error)) {
log_config = *parsed_log;
}
if (!options.log_file.empty()) log_config.file = options.log_file;
if (!options.log_level.empty()) log_config.level = options.log_level;
if (options.log_console) log_config.console = true;
aiagent::ConfigureDefaultLogger(log_config.level, log_config.file, log_config.console,
log_config.max_size_bytes, log_config.max_files);
这样后续所有模块(DB、模型、运行时、网关)产生的日志都能落到同一个 Logger 上,request id 也才能从 Web 一路串到 DeepSeek。
3.3 组件构造:栈对象与 shared_ptr 的分工
这一段是 main() 的核心。先把持久化层建好,再建模型客户端,最后才是 AgentRuntime:
php
// 1. 持久化:任务库 + 会话元数据(同目录的 JSON)
auto task_store = std::make_shared<a2a::server::SqliteTaskStore>(db_path.string());
std::filesystem::path meta_path = db_path;
meta_path.replace_filename("aiagent_sessions.json");
aiagent::agent::SessionMetaStore session_meta(meta_path.string());
// 2. 模型客户端
auto model = std::make_shared<aiagent::llm::DeepSeekClient>(*config);
// 3. 运行时:system prompt 直接写在这里,可注入工具/压缩/指令
aiagent::agent::AgentRuntime runtime(model, coding_system_prompt);
runtime.SetFallbackWorkingDirectory(std::filesystem::current_path().string());
// 4. 工具:内置 coding 工具先加,MCP 桥稍后按需追加
auto tools = std::make_shared<aiagent::tools::CompositeToolProvider>();
tools->Add(std::make_shared<aiagent::tools::LocalCodingTools>(
std::filesystem::current_path().string()));
runtime.SetToolProvider(tools);
这里的持有关系值得单独画一张图,因为它决定了后面 lambda 怎么捕获:
AgentRuntime、AgentServer、WebGateway 都是栈对象,它们只活到 main() 结束 ;model、tools、task_store 用 shared_ptr 是因为它们被运行时和多个 handler 同时引用。相应地,所有回调都用 [&] 按引用捕获这些栈对象,只要不把 WebGateway 或 AgentServer 挪出 main(),生命周期就是安全的。这是一个很典型的 C++ 手写 DI 取舍:省掉了容器和所有权管理,代价是入口文件的局部变量不能随意转移。
coding_system_prompt 是硬编码在 main.cpp 里的多行字符串(src/main.cpp),内容要求模型「用户要改文件就必须在同一轮调用 write/edit,不能只给计划」。它没有走配置,属于当前的一个已知限制:想改提示词得改代码重编。演进记录里 2026-09-10 08:36 那次改动就是专门调这段文字的------早期模型经常只输出一份计划就结束,加了「不许只计划不写文件」的约束后才稳定下来。
3.4 可选的 MCP:加载失败只降级,不退出
MCP 桥是可选的,代码先看 [mcp] 段是否显式启用,没有就退回到「vendor 下有没有构建产物」的自动判断:
rust
if (parsed_mcp.has_value() && parsed_mcp->enabled) {
mcp_config = *parsed_mcp;
} else {
mcp_config.binary = options.mcp_binary.empty()
? "vendor/mcp-extension-platform/build/mcp_server/mcp_server"
: options.mcp_binary;
// plugins_dir / python_path 同理,未指定时用 vendor 下的默认值
mcp_config.enabled = std::filesystem::exists(mcp_config.binary);
}
auto mcp_bridge = std::make_shared<aiagent::mcp::MCPClientBridge>();
if (mcp_config.enabled) {
try {
mcp_bridge->Start(mcp_config);
tools->Add(mcp_bridge); // 追加进同一个 CompositeToolProvider
std::cout << "MCP tools loaded: " << mcp_bridge->ListTools().size() << '\n';
} catch (const std::exception& e) {
std::cerr << "MCP disabled: " << e.what() << '\n';
mcp_bridge.reset(); // 失败就丢掉,进程照常跑
}
} else {
std::cout << "MCP disabled (binary not found)\n";
}
这段的设计意图是:MCP 只是能力扩展,不是服务的前置条件 。子进程启动失败、协议握手失败都不应该让整个 Agent 起不来,丢一条 MCP disabled 日志继续跑即可。启动输出里那句 MCP disabled (binary not found) 就是这里打出来的,上一篇已经见过。要注意的是 tools->Add(mcp_bridge) 加进的是同一个 CompositeToolProvider,所以内置工具和 MCP 工具在 AgentRuntime 眼里没有区别,重名时以先加入者为准(内置工具优先)。
3.5 A2A 接线:一个 lambda 接住 SendMessage
A2A 服务端的构造只有两行:
vbscript
a2a::server::AgentServer agent_server(BuildAgentCard(options.port), task_store);
agent_server.task_manager().SetMessageHandler(
[&](const a2a::models::SendMessageRequest& request) {
std::vector<a2a::models::Message> history =
LoadLatestHistory(history_cache, *task_store, request.message.context_id);
history.push_back(request.message);
auto response = runtime.HandleMessage(request.message, history,
request.message.context_id);
if (!request.message.context_id.empty() && response.IsTask()) {
history_cache.Put(request.message.context_id, response.GetTask().history);
}
return response;
});
BuildAgentCard(options.port) 生成对外暴露的 Agent Card,其中 capabilities.streaming = false、push_notifications = false(BuildAgentCard,src/main.cpp)。也就是说,外部 A2A 客户端发现这张卡时,看到的是非流式能力;A2A 这条路径走的是同步的 HandleMessage。AgentHttpServer 本身是支持 SSE 的(A2A 库的 agent_http_server.hpp 里有说明),只是当前 card 没有声明,这里如实暴露了自己的能力边界。
回调做的事很少:读历史 → 拼上本轮用户消息 → 交给 AgentRuntime → 把返回的历史写回缓存 。真正的对话逻辑、工具循环、压缩都在 AgentRuntime 内部。这段代码还顺带说明了两个模块的边界:
AgentRuntime不认识a2a::Task,只收发a2a::models::Message列表;TaskManager负责补全 task id、状态流转和入库,main只负责在成功后刷新HistoryCache。
history_cache 是 2026-09-08 为长会话加的内存缓存,把每次请求取历史从全表扫描 ListAll()(O(n²))降成 O(1)。LoadLatestHistory 的注释里写清了一个容易踩的点:
arduino
// 缓存优先地取某个会话的最新历史;缓存未命中时回退存储层。
// 选取写入时间最新的任务:手动/自动压缩会写入更短的历史,比较条数会选错。
同一个 context_id 下可能有多条 task。压缩会把更短的历史作为新 task 落库,如果按「历史条数最多」选,反而会选到压缩前的旧版本。所以这里比较的是 task.status.timestamp,取写入时间最新的那条,而不是最多的一条。这是压缩功能上线后才暴露出来的问题,属于「读代码容易忽略、但注释里已经写明」的典型细节。
3.6 对话接口接线:coding agent 走的流式路径
WebGateway 监听 18081,是后端对外的对话接口。终端 coding agent(de 拉起的 cli/cli.mjs)就是通过它工作的:cli/session.mjs 里调用 /api/v1/chat/stream 发起对话、/api/v1/chat/cancel 中断、/api/v1/sessions* 做会话管理。浏览器前端 web/node_frontend(默认监听 3000)只是把 /api/* 反向代理到 18081,再叠一个最小聊天页面,当初是为了随手验证链路,功能停在「能聊天」,不是产品重点。所以这一节的流式接线,服务的主要对象就是 coding agent。
WebGateway 的构造带两个依赖:AgentServer 和 task_store。构造之后,main 连续注册了九个 Set*Handler,按用途可以分成四组:
- 历史与压缩 :
SetHistoryCache、SetCompactHandler、SetCompactionInfoHandler; - 会话管理 :
SetSessionMetaStore、SetSessionDetailsHandler、SetDeleteHandler、SetCloneHandler; - 工具清单 :
SetToolsHandler; - 流式对话 :
SetStreamEventHandler。
其中流式那条是主路径,也是整篇里最关键的接线:
rust
web_gateway.SetStreamEventHandler(
[&](const std::string& session_id, const std::string& content,
const std::string& cwd, const aiagent::agent::AgentEventCallback& on_event,
const WebGateway::CancelCallback& should_cancel) {
if (!cwd.empty()) {
session_meta.SetCwd(session_id, cwd); // 记住每个会话最近的工作目录
}
auto history = LoadLatestHistory(history_cache, *task_store, session_id);
auto user = a2a::models::MakeTextMessage(a2a::models::Role::kUser,
content, "text/plain");
history.push_back(user);
const std::string reply = runtime.HandleMessageStreamingEvents(
user, history, session_id, on_event, cwd, should_cancel);
a2a::models::Task task;
task.id = std::string(a2a::core::NewUuid());
task.context_id = session_id;
task.status = a2a::models::MakeTaskStatus(a2a::models::TaskState::kCompleted);
task.history = std::move(history);
task.history.push_back(a2a::models::MakeTextMessage(
a2a::models::Role::kAgent, reply, "text/plain"));
task_store->Put(task);
history_cache.Put(session_id, std::move(task.history));
});
on_event 是一个 std::function<void(const AgentEvent&)>,由 WebGateway 传进来,负责把事件转成 SSE 帧写回 HTTP 响应;should_cancel 则由客户端「取消」接口驱动(coding agent 里是流式输出期间按 Esc),让工具执行和模型轮次可以提前中断。main 只负责把这两个回调透传给 AgentRuntime,自己不关心 SSE 的格式------那是 Web 层的事。
这里有一个容易看错的地方:对话接口的同步和流式两条路,在 main 里接法并不一样 。WebGateway 的 ChatOnce(/api/v1/chat)会构造 SendMessageRequest 调 agent_server_.task_manager().SendMessage(),绕回了上面 3.5 那个 handler;而 coding agent 用的 /api/v1/chat/stream 直接调用这里注入的 SetStreamEventHandler,不经过 TaskManager。两条路最后都落到同一个 AgentRuntime 上,但前者的历史由 TaskManager 维护,后者由这段 lambda 自己 Put 落库。读 Web Gateway 那篇时,这个分叉是关键。
3.7 启动、等待信号与关闭
两个服务在主线程串行启动,Web 先、A2A 后,任一失败都直接退出并打印具体端口:
c
if (!web_gateway.Start("127.0.0.1", options.web_port)) {
std::cerr << "failed to start Web Gateway on port " << options.web_port << '\n';
return 1;
}
a2a::server::AgentHttpServer http_server(agent_server);
if (!http_server.Start("127.0.0.1", options.port)) {
std::cerr << "failed to start A2A HTTP server on port " << options.port << '\n';
return 1;
}
std::signal(SIGINT, OnSignal);
std::signal(SIGTERM, OnSignal);
while (!g_stop_requested) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
http_server.Stop();
web_gateway.Stop();
g_stop_requested 是 volatile std::sig_atomic_t,信号处理函数只置位、不做其他事,主线程每 100ms 检查一次,这是信号安全的常规写法。两个服务各跑在自己的监听线程上,主线程空转等待。
关闭顺序和启动顺序相反:先停 A2A,再停 Web。WebGateway 的析构函数里也会调一次 Stop(),所以这里显式调用是为了让停止在 main 返回前完成。需要注意的是 A2A 服务端和 Web 网关的 Stop() 都只是停监听并 join 线程,并不会打断正在执行的模型请求------这一点在工具执行耗时较长时会表现为「按 Ctrl+C 后要等当前请求跑完才退出」。对本地工具可以接受,对外部署则要考虑加请求级取消。
4. 那些值得说的细节
入口文件在持续变长。 现在 main.cpp 607 行,其中一大半是 lambda:会话删除、克隆、压缩、cwd 持久化、工具清单......每加一个 REST 接口,入口就多一段。好处是「接线全在一处」,新读者从一个文件能看全整体;坏处是这个文件已经接近可维护性的边际,后续更适合把会话管理这类成组 handler 挪到独立的装配函数或一个 AppContext 里。这是现状,不是已经解决的问题。
lambda 捕获引用带来的约束。 所有 handler 都写 [&],捕获 runtime、task_store、history_cache、session_meta 等局部变量。它能省掉大量 shared_ptr,但要求这些对象活得比 handler 长。由于 handler 都注册在 main 作用域内的对象上,当前是安全的;一旦有人把 WebGateway 或 AgentServer 改成在堆上长期存在,这套捕获就会变成悬垂引用。换句话说,main 的 lambda 风格和对象的栈/堆布局是绑定的,改一处要同时改另一处。
跨线程共享状态需要自己加锁。 compaction_records 这个 std::map 被压缩回调写、被 GET /compaction 读,两者可能在不同线程上,所以 main 里配了一个 std::mutex compaction_records_mutex,两处访问都先加锁。HistoryCache 和 SqliteTaskStore 内部已各自线程安全,入口这边不需要再套锁。这也是手写 DI 的代价:哪些共享、哪些不用锁,全靠开发者自己判断,没有容器帮你标注作用域。
工具轮次的演进。 AgentRuntime 里 max_tool_rounds_ 默认是 0,表示不限制轮次,对齐主流 agent loop「只要有 tool_calls 就继续」。这个默认值是两次调整后的结果:最早版本上限 8,长任务会静默截断在半截计划上;后来改成 24 并明确区分「自然结束」和「打到上限」;再后来干脆默认不限制,只保留 SetMaxToolRounds() 给测试或显式安全限制用。入口这里没有调用它,所以线上跑的就是无上限行为。
system prompt 硬编码在入口。 coding_system_prompt 是 src/main.cpp 里的一段多行字符串,没有对应的配置项。它承载了「必须真的写文件」这种会影响成败的行为约束,却需要改代码才能调整。把提示词外置到配置文件或独立的 prompt 文件,是这个项目下一步可以改进的点之一。
5. 测试与验证
main.cpp 本身没有单元测试------它只做组装,测它意义不大。但两条链路的接线方式在测试里有最小复刻,可以用来对照:
tests/m0_smoke_test.cpp:AgentServer+SetMessageHandler+AgentHttpServer,再用A2AClient走一遍SendMessage,验证「本地起 A2A 服务并回环」这条路径。tests/web_gateway_test.cpp:AgentRuntime+SetMessageHandler+WebGateway+SetStreamHandler,验证 coding agent 依赖的 REST 与 SSE 接口。
跑测试不需要 API Key:
css
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failure # 期望 11/11 通过
ctest --test-dir build -R aiagent_web_gateway -V # 只看对话接口接线
想直接验证入口的默认值,可以 grep 出来对照启动日志:
bash
rg -n "18080|18081|conf/conf.ini|aiagent_tasks.db" src/main.cpp
接真实模型后,用三条 curl 做端到端自检:
bash
./build/aiagent_app --config conf/conf.ini --db build/db/aiagent_tasks.db &
curl -s http://127.0.0.1:18081/health
curl -s http://127.0.0.1:18081/api/v1/tools
curl -s http://127.0.0.1:18080/.well-known/agent-card.json
Agent Card 返回的 JSON 里 capabilities.streaming 是 false,和 3.5 节讲的 BuildAgentCard 一致;如果哪天改成 true,说明入口的能力声明变了,值得回头核对实现。
6. 小结
main.cpp是手写的依赖装配:先解析选项、装载配置、启动日志,再创建持久化与模型,然后构造AgentRuntime并注入工具/指令/压缩,最后注册回调、启动两个服务。- 依赖方向很清晰:
AgentRuntime只认Message列表和IModelClient/IToolProvider抽象,A2A 与对话接口都是它的上层入口;外部 Agent 走 A2A 的同步SendMessage,coding agent 走WebGateway的流式SetStreamEventHandler。 - 失败策略分两级:DeepSeek 配置缺失直接退出,MCP 加载失败只降级;跨线程共享的
compaction_records显式加锁,历史读取按写入时间而非条数取最新。 - 入口的代价是它越来越长、system prompt 硬编码、lambda 捕获引用与对象生命周期绑定,这几处是后续可以改进的地方。
下一篇进入 DeepSeekClient,看 main.cpp 交给 AgentRuntime 的那个 IModelClient 背后,同步、SSE 流式和 tool_calls 分别是怎么实现的。