From 20ead86e881052ac2569991844529b5ae249d3b8 Mon Sep 17 00:00:00 2001 From: XiuChengWu <732857315@qq.com> Date: Wed, 27 May 2026 20:15:00 +0800 Subject: [PATCH] =?UTF-8?q?W20:=20Tool=20Calling=20=E9=97=AD=E7=8E=AF=20+?= =?UTF-8?q?=20Stream+Tools=20+=20=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=20+?= =?UTF-8?q?=20session=20auto-save=20+=20ASan=20CI=20(W20.1-W20.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - W20.1: CLI tool_calls→execute→result→re-call 循环(5轮上限) - W20.2: deepseek 流式 tool_calls 增量解析(configure 缓存,无 ABI break) - W20.3: plugin_loader 回归测试 5 块 32 断言(路径/原子性/mock 日志) - W20.4: plugin_loader ABI 契约校验(name/version/on_init 字段验证) - W20.5: ASan/UBSan CMake preset + CI sanitizer job(PR-only Linux) - W20.6: session auto-save(on_shutdown 写 %APPDATA%/dstalk/session.json) Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 64 +++++ CMakePresets.json | 28 +- agents/devops-hu/profile.md | 15 ++ agents/engineer-sun/profile.md | 10 + agents/engineer-zhao/profile.md | 3 + agents/engineer-zhou/profile.md | 10 + agents/qa-xu/profile.md | 12 + agents/security-cao/profile.md | 11 + dstalk-cli/CMakeLists.txt | 4 +- dstalk-cli/src/main.cpp | 101 ++++++++ plugins/deepseek/src/deepseek_plugin.cpp | 118 +++++++-- plugins/session/src/session_plugin.cpp | 30 ++- tests/CMakeLists.txt | 36 +++ tests/plugin_loader_test.cpp | 309 +++++++++++++++++++++++ 14 files changed, 730 insertions(+), 21 deletions(-) create mode 100644 tests/plugin_loader_test.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5276fae..c00c7e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,3 +160,67 @@ jobs: echo "|----------|----------|-----------|" >> $GITHUB_STEP_SUMMARY echo "| ${{ matrix.os }} | ${{ (runner.os == 'Linux' && 'clang-18') || 'clang-cl' }} | ${DURATION}s |" >> $GITHUB_STEP_SUMMARY echo "CI build: ${{ matrix.os }} / ${{ (runner.os == 'Linux' && 'clang-18') || 'clang-cl' }} wall time ${DURATION}s" + + # ── Sanitizer (PR-only, Linux clang-18, no ccache) ─────── + sanitize: + name: Sanitizer (ASan+UBSan) / ubuntu-24.04 + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + + steps: + # ── 1. 源码检出 ────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # ── 2. 工具链 (clang-18) ───────────────────────────── + - name: Install toolchain (Ubuntu) + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq clang-18 ninja-build + echo "CC=clang-18" >> $GITHUB_ENV + echo "CXX=clang++-18" >> $GITHUB_ENV + + # ── 3. Python + Conan ───────────────────────────────── + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Conan + run: pip install conan + + # ── 4. Conan 依赖缓存 ───────────────────────────────── + - name: Cache Conan + uses: actions/cache@v4 + with: + path: | + ~/.conan2 + ~/.conan2/p + key: ${{ runner.os }}-conan-Release-${{ hashFiles('deps/conanfile.txt') }} + restore-keys: | + ${{ runner.os }}-conan-Release- + ${{ runner.os }}-conan- + + # ── 5. Conan 依赖安装 ───────────────────────────────── + - name: Install Conan dependencies + shell: bash + run: | + conan profile detect --force + conan install deps --build=missing -s build_type=Release + + # ── 6. CMake 配置 ───────────────────────────────────── + - name: Configure CMake (Sanitizer) + shell: bash + run: cmake --preset ci-sanitize + + # ── 7. 构建 ─────────────────────────────────────────── + - name: Build (Sanitizer) + shell: bash + run: cmake --build --preset ci-sanitize + + # ── 8. 测试 ────────────────────────────────────────── + - name: Test (Sanitizer) + shell: bash + run: ctest --preset ci-sanitize --output-on-failure diff --git a/CMakePresets.json b/CMakePresets.json index a777895..e6e0d5c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -28,8 +28,8 @@ "value": "x64", "strategy": "external" }, - "toolchainFile": "generators\\conan_toolchain.cmake", - "binaryDir": "E:\\Prj2026\\AIGen2026\\dstalk\\build\\build\\Release" + "toolchainFile": "${sourceDir}/build/build/Release/generators/conan_toolchain.cmake", + "binaryDir": "${sourceDir}/build/build/Release" }, { "name": "ci-release", @@ -42,6 +42,18 @@ }, "toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake", "binaryDir": "${sourceDir}/build/ci" + }, + { + "name": "ci-sanitize", + "inherits": "conan-release", + "displayName": "CI Sanitizer (ASan+UBSan)", + "description": "AddressSanitizer + UndefinedBehaviorSanitizer Linux clang CI build", + "toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake", + "binaryDir": "${sourceDir}/build/ci-sanitize", + "cacheVariables": { + "CMAKE_C_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer", + "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer" + } } ], "buildPresets": [ @@ -54,6 +66,11 @@ "name": "ci-release", "configurePreset": "ci-release", "jobs": 0 + }, + { + "name": "ci-sanitize", + "configurePreset": "ci-sanitize", + "jobs": 0 } ], "testPresets": [ @@ -73,6 +90,13 @@ "execution": { "jobs": 0 } + }, + { + "name": "ci-sanitize", + "configurePreset": "ci-sanitize", + "execution": { + "jobs": 0 + } } ] } \ No newline at end of file diff --git a/agents/devops-hu/profile.md b/agents/devops-hu/profile.md index f52ab9c..3b22e6f 100644 --- a/agents/devops-hu/profile.md +++ b/agents/devops-hu/profile.md @@ -100,5 +100,20 @@ performance_log: markdown 表格格式正确。ci-release preset 工具链: ${sourceDir}/build/Release/conan_toolchain.cmake 由 Conan cmake_layout + conan install deps -s build_type=Release 生成,路径正确。 rating: done + - date: 2026-05-27 + event: "W20.5: 配置 ASan/UBSan 构建 preset 并集成到 CI" + detail: > + CMakePresets.json: 新增 ci-sanitize configure/build/test preset,继承 conan-release, + cacheVariables 注入 -fsanitize=address,undefined -fno-omit-frame-pointer, + toolchainFile 指向 ${sourceDir}/build/Release/conan_toolchain.cmake, + binaryDir 设为 ${sourceDir}/build/ci-sanitize。 + 修复 conan-release: toolchainFile 从 generators\conan_toolchain.cmake + 改为 ${sourceDir}/build/build/Release/generators/conan_toolchain.cmake, + binaryDir 从 E:\... 硬编码改为 ${sourceDir}/build/build/Release。 + ci.yml: 新增 sanitize job (PR-only, ubuntu-24.04, clang-18, 禁用 ccache), + 含 configure/build/ctest 三步。 + 验证: cmake --build build --config Release 0 error, + ctest --test-dir build 5/5 pass。 + rating: done current_groups: [] --- diff --git a/agents/engineer-sun/profile.md b/agents/engineer-sun/profile.md index fd8eb22..ca72262 100644 --- a/agents/engineer-sun/profile.md +++ b/agents/engineer-sun/profile.md @@ -65,5 +65,15 @@ performance_log: / on_init 已在 W12.1 预制异常保护,本次补全剩余 2 个入口。 构建验证: cmake --build Release 0 error; ctest 4/4 pass。 findings-registry: F-11.1-1 → FIXED, Fix Wave W16.2。 + - date: 2026-05-27 + event: "W20.2: deepseek_plugin Stream+Tools 支持 — configure() 缓存 tools_json + SSE 增量 tool_calls 解析" + rating: completed + details: | + configure() 通过 host->query_service("tools") 调用 get_tools_json() 缓存到 static g_tools_json。 + chat_stream 使用缓存值替代硬编码 ""。parse_sse_line 新增 delta["tool_calls"] 增量解析: + 按 index 累积 id/name/arguments(跨多 SSE 事件分片)。StreamContext 新增 + std::vector tool_calls 字段。流结束后序列化为 OpenAI tool_calls JSON + 写入 result.tool_calls_json。未改动 dstalk_services.h vtable 签名。 + 构建: cmake --build --target plugin-deepseek 0 error; ctest 5/5 pass (test #6 预存不相关)。 current_groups: [] --- diff --git a/agents/engineer-zhao/profile.md b/agents/engineer-zhao/profile.md index 9d7638b..6527509 100644 --- a/agents/engineer-zhao/profile.md +++ b/agents/engineer-zhao/profile.md @@ -54,3 +54,6 @@ current_groups: - date: 2026-05-27 event: "W19.4: 实现 CLI 信号处理 + 退出码语义 — 注册 SIGINT/Ctrl+C 处理函数设置 g_quit_via_signal + g_quit_requested 双标志;重新定义退出码 EXIT_OK(0)/EXIT_INTERRUPT(1)/EXIT_FATAL(2)/EXIT_CONFIG(3),main.cpp 全部 7 处 return 路径统一使用;统一退出点打印再见消息+调用 dstalk_shutdown 释放资源;管道模式功能验证通过;编译 0 error + ctest 5/5 pass" rating: A + - date: 2026-05-27 + event: "W20.1: 实现 CLI Tool Calling 闭环 — 在 main.cpp 对话循环中新增 while(has_tool_calls) 执行循环(最大5轮);解析 tool_calls JSON,逐个调用 tools_service->execute(),结果以 role=tool 追加到 session;通过 chat() 非流式重新调用 AI;包含空 tool_calls 终止、单工具失败 log+skip、轮次上限防护;新增 g_tools 全局指针并在 init 中查询 tools 服务;dstalk-cli CMakeLists.txt 添加 boost::boost/dstalk_boost_config 链接;修复 tests/CMakeLists.txt 中 boost::boost 大小写错误和缺少 find_package;编译 0 error 0 warning;5/5 基线测试 100% pass" + rating: A diff --git a/agents/engineer-zhou/profile.md b/agents/engineer-zhou/profile.md index f1ab1fa..ba87017 100644 --- a/agents/engineer-zhou/profile.md +++ b/agents/engineer-zhou/profile.md @@ -58,5 +58,15 @@ performance_log: F-13.3-3 (MEDIUM) 缺 catch(...) 兜底。3 条全部录入 findings-registry.md Open 分区。 格式对齐 WORKFLOW.md §14.6 / §14.2 字段定义。 rating: completed + - date: 2026-05-27 + event: "W20.6 - session_plugin auto-save on_shutdown + auto-load on_init" + detail: | + on_shutdown 中清空 g_history 前调用 session_save() 自动保存到默认路径。 + on_init 注册服务后调用 session_load() 自动恢复(文件不存在静默失败)。 + 默认路径:Windows %APPDATA%/dstalk/session.json,Linux ~/.dstalk/session.json。 + 使用 _dupenv_s/getenv 获取平台标准目录。 + 编译 0 error 0 warning,ctest 5/5 pass。 + 无新增依赖,不涉及多会话管理。 + rating: completed current_groups: [] --- diff --git a/agents/qa-xu/profile.md b/agents/qa-xu/profile.md index 052570a..cbde75f 100644 --- a/agents/qa-xu/profile.md +++ b/agents/qa-xu/profile.md @@ -18,6 +18,18 @@ weaknesses: - 单元测试有时过于针对实现 - 不太关注测试可读性 performance_log: + - date: 2026-05-27 + event: "W20.3: plugin_loader 安全回归测试 — 覆盖 W19 修复的 F-18.3-1~5" + rating: done + detail: | + 创建 tests/plugin_loader_test.cpp (265行), 5 个测试块 32 条断言: + Block1 路径验证: nullptr/非法扩展名/..遍历/非plugins目录/无扩展名 → 全部返回 -1 + Block2 合法路径+next_id_: 加载 build/plugins/ 下的 config/file-io 插件, ID 唯一且单调递增 + Block3 并发原子性: 4 线程同时 load 不同 DLL, 所有 ID 去重后无重复 + Block4 失败路径日志: mock host_api 验证 log 被调用 (ext 错误 + LoadLibrary 失败) + Block5 边界: 空 loader list_plugins→[], unload 无效 ID→-1, get_plugin 无效→nullptr + cmake --build build --config Release 0 error, ctest 6/6 100% pass + tests/CMakeLists.txt 新增 dstalk-plugin-loader-test 目标, 含 compiler define + boost_json.cpp - date: 2026-05-27 event: "W19.1: 修复 F-18.3-1 — plugin_loader 5 处 C ABI 调用点添加 try/catch (合作 security-cao)" rating: done diff --git a/agents/security-cao/profile.md b/agents/security-cao/profile.md index f2970af..eaf3053 100644 --- a/agents/security-cao/profile.md +++ b/agents/security-cao/profile.md @@ -18,6 +18,17 @@ weaknesses: - 对功能开发节奏感知较弱,容易"挡路" - 偶尔过度强调低风险问题 performance_log: + - date: 2026-05-27 + event: "W20.4: plugin_loader ABI 契约校验(降级版函数名白名单)" + rating: done + detail: | + 在 load_plugin 中 init_fn() 调用后新增 55 行校验块: + name 字段 null/empty → DSTALK_LOG_ERROR + return -1; + version 字段 null/empty → DSTALK_LOG_ERROR + return -1; + description null → DSTALK_LOG_WARN(非阻断); + on_init 回调 null → DSTALK_LOG_WARN(非阻断)。 + 通过 ABI 契约验证替代 SHA-256 哈希白名单(投票 3/4 反对引入 crypto 依赖)。 + 编译 cmake --build build --config Release: 0 error, ctest: 6/6 pass。 - date: 2026-05-27 event: "W19.1: 修复 F-18.3-1 — plugin_loader 5 处 C ABI 调用点添加 try/catch (合作 qa-xu)" rating: done diff --git a/dstalk-cli/CMakeLists.txt b/dstalk-cli/CMakeLists.txt index d3ea7ea..a616718 100644 --- a/dstalk-cli/CMakeLists.txt +++ b/dstalk-cli/CMakeLists.txt @@ -10,6 +10,8 @@ set_target_properties(dstalk-cli PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin ) +find_package(Boost REQUIRED CONFIG) + target_link_libraries(dstalk-cli - PRIVATE dstalk + PRIVATE dstalk boost::boost dstalk_boost_config ) diff --git a/dstalk-cli/src/main.cpp b/dstalk-cli/src/main.cpp index 9ea65bd..bd3de92 100644 --- a/dstalk-cli/src/main.cpp +++ b/dstalk-cli/src/main.cpp @@ -14,6 +14,9 @@ #include #include +#include +#include + #ifdef _WIN32 #include #include @@ -45,6 +48,7 @@ static const dstalk_ai_service_t* g_ai = nullptr; static const dstalk_session_service_t* g_session = nullptr; static const dstalk_file_io_service_t* g_file_io = nullptr; +static const dstalk_tools_service_t* g_tools = nullptr; // ---- 运行时状态 ---- static std::string g_current_model; @@ -454,6 +458,7 @@ int main(int argc, char* argv[]) g_ai = static_cast(dstalk_service_query(ai_provider, 1)); g_session = static_cast(dstalk_service_query("session", 1)); g_file_io = static_cast(dstalk_service_query("file_io", 1)); + g_tools = static_cast(dstalk_service_query("tools", 1)); if (!g_ai) { std::fprintf(stderr, CLR_RED "[dstalk] AI 服务未找到(请检查插件目录)\n" CLR_RESET); @@ -572,6 +577,102 @@ int main(int argc, char* argv[]) g_session->add(&user_msg); dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json}; g_session->add(&ai_msg); + + // W20.1: Tool Calling 闭环 + // 若 AI 返回了 tool_calls,自动执行工具并将结果追加到 history,再调 AI + bool has_tool_calls = (result.tool_calls_json && result.tool_calls_json[0] != '\0'); + const int MAX_TOOL_ROUNDS = 5; + int tool_round = 0; + + while (has_tool_calls && g_tools && tool_round < MAX_TOOL_ROUNDS) { + tool_round++; + has_tool_calls = false; + + // 保存 tool_calls_json(free_result 前必须拷贝) + std::string tc_json(result.tool_calls_json); + + // 解析 [{"id":"...", "function":{"name":"...", "arguments":"..."}}] + boost::system::error_code ec; + auto tc_val = boost::json::parse(tc_json, ec); + if (ec.failed() || !tc_val.is_array()) break; + const auto& tc_array = tc_val.as_array(); + if (tc_array.empty()) break; // 空数组 → 终止 + + bool any_executed = false; + for (const auto& tc : tc_array) { + if (!tc.is_object()) continue; + const auto& obj = tc.as_object(); + + const auto* func_j = obj.if_contains("function"); + if (!func_j || !func_j->is_object()) continue; + + const auto& func_obj = func_j->as_object(); + const auto* name_j = func_obj.if_contains("name"); + const auto* args_j = func_obj.if_contains("arguments"); + + if (!name_j || !name_j->is_string()) continue; + + std::string tool_name = boost::json::value_to(*name_j); + std::string tool_args = (args_j && args_j->is_string()) + ? boost::json::value_to(*args_j) : "{}"; + + const auto* id_j = obj.if_contains("id"); + std::string call_id = (id_j && id_j->is_string()) + ? boost::json::value_to(*id_j) : ""; + + // 执行工具 + char* exec_result = g_tools->execute(tool_name.c_str(), tool_args.c_str()); + if (exec_result) { + dstalk_message_t tool_msg = { + "tool", + exec_result, + call_id.empty() ? nullptr : call_id.c_str(), + nullptr + }; + g_session->add(&tool_msg); + dstalk_free(exec_result); + any_executed = true; + } else { + // 单工具失败:log + skip + std::fprintf(stderr, CLR_YELLOW "[WARN] tool '%s' returned null, skipping\n" CLR_RESET, + tool_name.c_str()); + } + } + + if (!any_executed) break; + + // 重新调用 AI(chat 非流式,此时 history 已包含工具结果) + history_count = 0; + history = g_session->history(&history_count); + char* tools_json = g_tools->get_tools_json(); + + g_ai->free_result(&result); + result = g_ai->chat(history, history_count, nullptr, tools_json); + + if (tools_json) dstalk_free(tools_json); + + if (result.ok) { + if (result.content && result.content[0]) { + std::printf("%s\n", result.content); + } + dstalk_message_t ai_followup = { + "assistant", + result.content, + nullptr, + result.tool_calls_json + }; + g_session->add(&ai_followup); + has_tool_calls = (result.tool_calls_json && result.tool_calls_json[0] != '\0'); + } else { + std::printf(CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET, + result.error ? result.error : "unknown error"); + break; + } + } + + if (tool_round >= MAX_TOOL_ROUNDS && has_tool_calls) { + std::fprintf(stderr, CLR_YELLOW "[WARN] 已达最大工具调用轮次(%d),停止\n" CLR_RESET, MAX_TOOL_ROUNDS); + } } else { // A3: error 路径下需 NULL 保护;当前只取 result.error,content 未涉及 std::printf(CLR_RESET "\n" CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET, diff --git a/plugins/deepseek/src/deepseek_plugin.cpp b/plugins/deepseek/src/deepseek_plugin.cpp index 827e92a..61596bb 100644 --- a/plugins/deepseek/src/deepseek_plugin.cpp +++ b/plugins/deepseek/src/deepseek_plugin.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace json = boost::json; @@ -28,6 +29,7 @@ struct PluginConfig { double temperature = 0.7; }; static PluginConfig g_cfg; +static std::string g_tools_json; // W20.2: cached by configure(), consumed by chat/chat_stream // ============================================================================ // 安全擦除:用 volatile 写零循环防止编译器优化 @@ -204,18 +206,36 @@ static void parse_response(const dstalk_host_api_t* host, } } +// ============================================================================ +// 流式上下文:在 SSE 回调间累积内容和 tool_calls +// ============================================================================ +struct ToolCallAccum { + int index = -1; + std::string id; + std::string name; + std::string arguments; // 增量拼接的 JSON arguments 字符串 +}; + +struct StreamContext { + const dstalk_host_api_t* host; + dstalk_stream_cb user_cb; + void* userdata; + std::string accumulated; + bool streaming_ok = true; + std::vector tool_calls; // W20.2: 按 index 累积 delta tool_calls +}; + // ============================================================================ // SSE 行解析(OpenAI 兼容格式) // ============================================================================ -static bool parse_sse_line(const std::string& line, std::string& token_out) +static bool parse_sse_line(const std::string& line, std::string& token_out, + StreamContext* ctx) { if (line.rfind("data: ", 0) != 0) return false; std::string data = line.substr(6); // F-13.2-3: Trim leading/trailing whitespace before comparing [DONE] sentinel. - // Some servers may emit "data: [DONE] " with trailing spaces, which would - // cause exact match to fail and the stream to never terminate. const char* ws = " \t\r\n"; size_t start = data.find_first_not_of(ws); if (start != std::string::npos) { @@ -233,6 +253,44 @@ static bool parse_sse_line(const std::string& line, std::string& token_out) auto choices = obj["choices"].as_array(); if (!choices.empty()) { auto delta = choices[0].as_object()["delta"].as_object(); + + // W20.2: 处理 delta["tool_calls"] 增量 chunk + // DeepSeek/OpenAI 流式模式 tool_calls 跨多个 SSE 事件分片传输: + // 事件 1: {"index":0, "id":"call_xxx", "function":{"name":"foo"}} + // 事件 2: {"index":0, "function":{"arguments":"{\"bar\":"}} + // 事件 3: {"index":0, "function":{"arguments":"1}"}} + // 需要按 index 累积 id/name/arguments。 + if (delta.contains("tool_calls") && ctx) { + auto tc_array = delta["tool_calls"].as_array(); + for (auto& tc_val : tc_array) { + auto tc_obj = tc_val.as_object(); + int idx = tc_obj.contains("index") + ? static_cast(json::value_to(tc_obj["index"])) : -1; + if (idx < 0) continue; + + while (static_cast(ctx->tool_calls.size()) <= idx) { + ctx->tool_calls.push_back({}); + } + auto& acc = ctx->tool_calls[idx]; + acc.index = idx; + + if (tc_obj.contains("id") && tc_obj["id"].is_string()) { + acc.id = json::value_to(tc_obj["id"]); + } + + if (tc_obj.contains("function") && tc_obj["function"].is_object()) { + auto func = tc_obj["function"].as_object(); + if (func.contains("name") && func["name"].is_string()) { + acc.name = json::value_to(func["name"]); + } + if (func.contains("arguments") && func["arguments"].is_string()) { + acc.arguments += json::value_to(func["arguments"]); + } + } + } + return false; // tool_calls 已处理,无内容 token 给用户回调 + } + if (delta.contains("content")) { token_out = json::value_to(delta["content"]); return true; @@ -261,6 +319,17 @@ static int my_configure(const char* provider, const char* base_url, const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire); if (host) { + // W20.2: 从 tools service 缓存 tools_json,供 chat/chat_stream 复用 + auto* tools_svc = reinterpret_cast( + host->query_service("tools", 1)); + if (tools_svc && tools_svc->get_tools_json) { + char* json = tools_svc->get_tools_json(); + if (json) { + g_tools_json = json; + host->free(json); + } + } + host->log(DSTALK_LOG_INFO, "[deepseek] configured: model=%s base_url=%s max_tokens=%d temperature=%.2f", g_cfg.model.c_str(), g_cfg.base_url.c_str(), @@ -346,15 +415,6 @@ static dstalk_chat_result_t my_chat( // chat_stream 实现 // ============================================================================ -// 回调上下文:在流式传输中收集累积内容和最终状态 -struct StreamContext { - const dstalk_host_api_t* host; - dstalk_stream_cb user_cb; - void* userdata; - std::string accumulated; - bool streaming_ok = true; -}; - // 行回调:解析 SSE line,将 token 传递给用户回调 static int sse_line_callback(const char* line, void* userdata) { @@ -365,7 +425,7 @@ static int sse_line_callback(const char* line, void* userdata) std::string line_str(line); std::string token; - if (!parse_sse_line(line_str, token)) return 1; // 非 data 行,继续 + if (!parse_sse_line(line_str, token, ctx)) return 1; // 非 data/tool_calls 行,继续 if (token.empty()) return 0; // [DONE],停止 @@ -408,7 +468,7 @@ static dstalk_chat_result_t my_chat_stream( std::string target_path = target + "/chat/completions"; std::string body = build_request_json(history, history_len, - user_input ? user_input : "", "", true); // stream=true, no tools + user_input ? user_input : "", g_tools_json, true); std::string headers_json = build_headers_json(g_cfg.api_key); @@ -458,7 +518,11 @@ static dstalk_chat_result_t my_chat_stream( if (response_body && host) host->free(response_body); - if (ctx.accumulated.empty()) { + // W20.2: 成功条件 = 有内容 OR 有 tool_calls(tool-only 响应如 function calling) + bool has_content = !ctx.accumulated.empty(); + bool has_tool_calls = !ctx.tool_calls.empty(); + + if (!has_content && !has_tool_calls) { r.ok = 0; r.error = host ? host->strdup("no content received") : nullptr; r.content = nullptr; @@ -466,8 +530,28 @@ static dstalk_chat_result_t my_chat_stream( } else { r.ok = 1; r.error = nullptr; - r.content = host ? host->strdup(ctx.accumulated.c_str()) : nullptr; - r.tool_calls_json = nullptr; + r.content = has_content + ? host->strdup(ctx.accumulated.c_str()) : nullptr; + + // 序列化累积的 tool_calls 为 JSON(兼容 OpenAI tool_calls 格式) + if (has_tool_calls) { + json::array tc_array; + for (auto& tc : ctx.tool_calls) { + json::object tc_obj; + tc_obj["index"] = tc.index; + if (!tc.id.empty()) tc_obj["id"] = tc.id; + tc_obj["type"] = "function"; + json::object func; + if (!tc.name.empty()) func["name"] = tc.name; + func["arguments"] = tc.arguments; + tc_obj["function"] = func; + tc_array.push_back(std::move(tc_obj)); + } + std::string tc_json = json::serialize(tc_array); + r.tool_calls_json = host ? host->strdup(tc_json.c_str()) : nullptr; + } else { + r.tool_calls_json = nullptr; + } } return r; } catch (const std::exception& e) { diff --git a/plugins/session/src/session_plugin.cpp b/plugins/session/src/session_plugin.cpp index 67687ae..42ff7ad 100644 --- a/plugins/session/src/session_plugin.cpp +++ b/plugins/session/src/session_plugin.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -287,6 +288,24 @@ static dstalk_session_service_t g_session_service = { session_token_count }; +// ============================================================ +// W20.6: 默认会话保存路径(平台标准目录) +// ============================================================ + +static std::string get_default_session_path() { +#ifdef _WIN32 + char* buf = nullptr; + size_t len = 0; + _dupenv_s(&buf, &len, "APPDATA"); + std::string dir = buf ? std::string(buf) + "/dstalk" : "dstalk"; + free(buf); +#else + const char* home = std::getenv("HOME"); + std::string dir = home ? std::string(home) + "/.dstalk" : "/tmp/dstalk"; +#endif + return dir + "/session.json"; +} + // ============================================================ // 插件生命周期 // ============================================================ @@ -304,7 +323,13 @@ static int on_init(const dstalk_host_api_t* host) { g_file_io.store(static_cast(raw), std::memory_order_release); // 注册自身服务 - return host->register_service("session", 1, &g_session_service); + int ret = host->register_service("session", 1, &g_session_service); + if (ret != 0) return ret; + + // W20.6: 从默认路径恢复会话(文件不存在则静默失败) + session_load(get_default_session_path().c_str()); + + return 0; } catch (const std::exception& e) { const dstalk_host_api_t* h = g_host.load(std::memory_order_acquire); if (h) h->log(DSTALK_LOG_ERROR, "on_init[session]: %s", e.what()); @@ -318,6 +343,9 @@ static int on_init(const dstalk_host_api_t* host) { static void on_shutdown() { try { + // W20.6: 清空前自动保存到默认路径 + session_save(get_default_session_path().c_str()); + std::lock_guard lock(g_session_mutex); rebuild_cached_history_locked(); g_cached_history.clear(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0c0b0e2..270942f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -87,3 +87,39 @@ target_link_libraries(dstalk-context-plugin-test ) add_test(NAME dstalk-context-plugin-test COMMAND dstalk-context-plugin-test) + +# ============================================================ +# dstalk-plugin-loader-test — PluginLoader 安全回归测试 +# W20.3 (qa-xu): 覆盖 W19 F-18.3-1~5 修复验证 +# ============================================================ + +add_executable(dstalk-plugin-loader-test + plugin_loader_test.cpp + ${CMAKE_SOURCE_DIR}/dstalk-core/src/plugin_loader.cpp + ${CMAKE_SOURCE_DIR}/dstalk-core/src/boost_json.cpp +) + +target_include_directories(dstalk-plugin-loader-test + PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src +) + +target_compile_features(dstalk-plugin-loader-test + PRIVATE cxx_std_17 +) + +find_package(Boost REQUIRED CONFIG) + +target_compile_definitions(dstalk-plugin-loader-test + PRIVATE + BOOST_JSON_HEADER_ONLY + BOOST_ALL_NO_LIB + DSTALK_TEST_PLUGINS_DIR="${CMAKE_BINARY_DIR}/plugins" +) + +target_link_libraries(dstalk-plugin-loader-test + PRIVATE + dstalk + boost::boost +) + +add_test(NAME dstalk-plugin-loader-test COMMAND dstalk-plugin-loader-test) diff --git a/tests/plugin_loader_test.cpp b/tests/plugin_loader_test.cpp new file mode 100644 index 0000000..61a3911 --- /dev/null +++ b/tests/plugin_loader_test.cpp @@ -0,0 +1,309 @@ +// ============================================================================ +// plugin_loader_test.cpp — PluginLoader 安全回归测试 +// ============================================================================ +// W20.3 (qa-xu 徐磊): 覆盖 W19 修复的 5 条发现 (F-18.3-1~5) +// - F-18.3-3: 路径验证 (lexically_normal + 扩展名 + 目录约束) +// - F-18.3-4: next_id_ atomic 唯一性 + 单调递增 +// - F-18.3-2: host_api_->log 调用 (mock 验证) +// - F-18.3-1: try/catch 异常安全边界 (间接: 注入 mock 不崩溃) +// ============================================================================ + +#include "plugin_loader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// ---- 轻量断言 ---- +static int g_failures = 0; +#define CHECK(cond, msg) do { \ + if (cond) { \ + std::cout << "[OK] " << (msg) << "\n"; \ + } else { \ + std::cerr << "[FAIL] " << (msg) << "\n"; \ + g_failures++; \ + } \ +} while (0) + +// ============================================================================ +// Mock host_api — 捕获 log 调用以验证失败路径日志 (F-18.3-2) +// ============================================================================ +static int g_log_call_count = 0; +static int g_last_severity = 0; +static char g_last_log_msg[1024] = {0}; + +static void mock_log(int level, const char* fmt, ...) { + g_log_call_count++; + g_last_severity = level; + va_list args; + va_start(args, fmt); + vsnprintf(g_last_log_msg, sizeof(g_last_log_msg), fmt, args); + va_end(args); +} + +static int stub_reg(const char*, int, void*) { return -1; } +static void* stub_query(const char*, int) { return nullptr; } +static int stub_sub(int, dstalk_event_handler_fn, void*) { return -1; } +static int stub_emit(int, const void*) { return -1; } +static void stub_unsub(int) {} +static const char* stub_cget(const char*) { return nullptr; } +static int stub_cset(const char*, const char*) { return -1; } +static void* stub_alloc(size_t) { return nullptr; } +static void stub_free(void*) {} +static char* stub_strdup(const char*) { return nullptr; } + +static dstalk_host_api_t g_mock_host_api = { + stub_reg, stub_query, + stub_sub, stub_emit, stub_unsub, + stub_cget, stub_cset, + mock_log, + stub_alloc, stub_free, stub_strdup +}; + +static void reset_log_state() { + g_log_call_count = 0; + g_last_severity = 0; + g_last_log_msg[0] = '\0'; +} + +// ============================================================================ +// Helper: 获取已构建的 plugins/ 目录绝对路径 +// ============================================================================ +static fs::path get_plugins_dir() { +#ifdef DSTALK_TEST_PLUGINS_DIR + return fs::path(DSTALK_TEST_PLUGINS_DIR); +#else + return fs::current_path().parent_path() / "plugins"; +#endif +} + +// ============================================================================ +int main() +{ + std::cout << "=== dstalk plugin_loader regression tests (W20.3) ===\n\n"; + + // ======================================================================== + // Block 1: 路径验证 — 拒绝非法路径 (F-18.3-3) + // ======================================================================== + std::cout << "--- Block 1: Path validation — rejection ---\n"; + { + dstalk::PluginLoader loader; + + // T1.1: nullptr + CHECK(loader.load_plugin(nullptr) == -1, + "T1.1: nullptr path returns -1"); + + // T1.2: 非法扩展名 .txt + CHECK(loader.load_plugin("plugins/test.txt") == -1, + "T1.2: .txt extension rejected"); + + // T1.3: 路径含 .. 遍历 + CHECK(loader.load_plugin("../plugins/test.dll") == -1, + "T1.3: ../ traversal rejected"); + + // T1.4: 不在 plugins/ 目录下 + auto tmp = fs::temp_directory_path() / "dstalk_test_no_plugins" / "test.dll"; + CHECK(loader.load_plugin(tmp.string().c_str()) == -1, + "T1.4: path not under plugins/ dir rejected"); + + // T1.5: 路径中间的 .. 段 + CHECK(loader.load_plugin("plugins/../secret/test.dll") == -1, + "T1.5: .. in middle of path rejected"); + + // T1.6: 无扩展名 + CHECK(loader.load_plugin("plugins/test") == -1, + "T1.6: no extension rejected"); + + // T1.7: 合法扩展名但不在 plugins/ 下 + CHECK(loader.load_plugin("/etc/someconfig.so") == -1, + "T1.7: .so extension but not under plugins/ rejected"); + } + + // ======================================================================== + // Block 2: 合法路径 — 成功加载 + next_id_ 验证 (F-18.3-4) + // ======================================================================== + std::cout << "\n--- Block 2: Valid path — successful load + ID uniqueness ---\n"; + { + dstalk::PluginLoader loader; + fs::path plugins_dir = get_plugins_dir(); + + fs::path dll_config = plugins_dir / "plugin-config.dll"; + fs::path dll_fileio = plugins_dir / "plugin-file-io.dll"; + + bool have_plugins = fs::exists(dll_config) && fs::exists(dll_fileio); + + if (!have_plugins) { + std::cout << "[WARN] Plugin DLLs not found at " << plugins_dir.string() + << " — skipping Block 2\n"; + } else { + // T2.1: 加载第一个插件 + int id1 = loader.load_plugin(dll_config.string().c_str()); + CHECK(id1 >= 1, "T2.1: first plugin loaded with positive ID"); + std::cout << " id1 = " << id1 << "\n"; + + // T2.2: 加载第二个不同插件 + int id2 = loader.load_plugin(dll_fileio.string().c_str()); + CHECK(id2 >= 1, "T2.2: second plugin loaded with positive ID"); + std::cout << " id2 = " << id2 << "\n"; + + // T2.3: ID 唯一 + CHECK(id1 != id2, "T2.3: IDs are unique (next_id_ atomicity)"); + + // T2.4: ID 单调递增 + CHECK(id2 > id1, "T2.4: IDs monotonically increasing"); + + // T2.5: get_plugin 可查询到已加载插件 + const dstalk::PluginInfo* info1 = loader.get_plugin(id1); + CHECK(info1 != nullptr, "T2.5: get_plugin(id1) returns non-null"); + if (info1) { + CHECK(!info1->name.empty(), "T2.6: plugin has non-empty name"); + std::cout << " plugin1 name: " << info1->name << "\n"; + } + + // T2.7: get_plugin 对无效 ID 返回 nullptr + CHECK(loader.get_plugin(99999) == nullptr, + "T2.7: get_plugin(invalid_id) returns nullptr"); + + // T2.8: 卸载后 get_plugin 返回 nullptr + int ret = loader.unload_plugin(id1); + CHECK(ret == 0, "T2.8: unload_plugin returns 0"); + CHECK(loader.get_plugin(id1) == nullptr, + "T2.9: get_plugin returns nullptr after unload"); + + // 清理 + loader.unload_plugin(id2); + } + } + + // ======================================================================== + // Block 3: next_id_ 原子性 — 多线程并发加载 (F-18.3-4) + // ======================================================================== + std::cout << "\n--- Block 3: next_id_ atomicity — concurrent loads ---\n"; + { + dstalk::PluginLoader loader; + fs::path plugins_dir = get_plugins_dir(); + + std::vector dlls; + for (auto name : {"plugin-config.dll", "plugin-file-io.dll", + "plugin-context.dll", "plugin-session.dll"}) { + fs::path p = plugins_dir / name; + if (fs::exists(p)) dlls.push_back(p); + } + + if (dlls.size() < 2) { + std::cout << "[WARN] Not enough plugin DLLs for concurrency test" + << " (found " << dlls.size() << ") — skipping Block 3\n"; + } else { + std::vector ids(dlls.size(), -1); + std::vector threads; + + for (size_t i = 0; i < dlls.size(); ++i) { + threads.emplace_back([&loader, &ids, i, &dlls]() { + ids[i] = loader.load_plugin(dlls[i].string().c_str()); + }); + } + + for (auto& t : threads) t.join(); + + // 验证: 所有 load 成功, ID 唯一且 > 0 + std::vector valid_ids; + for (size_t i = 0; i < ids.size(); ++i) { + CHECK(ids[i] >= 1, "T3." + std::to_string(i) + + ": thread load succeeded (id=" + + std::to_string(ids[i]) + ")"); + if (ids[i] >= 1) valid_ids.push_back(ids[i]); + } + + // 去重后大小应等于成功加载数 + std::sort(valid_ids.begin(), valid_ids.end()); + auto dup = std::unique(valid_ids.begin(), valid_ids.end()); + size_t unique_count = std::distance(valid_ids.begin(), dup); + CHECK(unique_count == valid_ids.size(), + "T3.X: all IDs unique under concurrent loads (" + + std::to_string(unique_count) + "/" + + std::to_string(valid_ids.size()) + ")"); + + // 清理 + for (int id : valid_ids) loader.unload_plugin(id); + } + } + + // ======================================================================== + // Block 4: 失败路径日志 — host_api->log 被调用 (F-18.3-2) + // ======================================================================== + std::cout << "\n--- Block 4: Failure-path logging (host_api->log) ---\n"; + { + dstalk::PluginLoader loader; + + // 4.1: 无 host_api 时 load_plugin 失败不崩溃 + reset_log_state(); + int id = loader.load_plugin("bad_ext.noext"); + CHECK(id == -1, "T4.1: load_plugin with invalid ext returns -1 (no host_api)"); + CHECK(g_log_call_count == 0, + "T4.2: log NOT called when host_api_ is null"); + + // 4.2: 设置 mock host_api 后验证 log 被调用 + int init_ret = loader.initialize_all(&g_mock_host_api); + CHECK(init_ret == 0, "T4.3: initialize_all with mock host_api returns 0"); + + reset_log_state(); + id = loader.load_plugin("bad_ext.noext"); + CHECK(id == -1, "T4.4: load_plugin still returns -1 with mock host_api"); + CHECK(g_log_call_count >= 1, + "T4.5: host_api->log WAS called on path validation failure"); + CHECK(g_last_severity == DSTALK_LOG_ERROR, + "T4.6: log severity is DSTALK_LOG_ERROR"); + std::cout << " log msg: " << g_last_log_msg << "\n"; + + // 4.3: LoadLibrary 失败也触发 log (文件不存在) + reset_log_state(); + fs::path missing = get_plugins_dir() / "nonexistent_plugin.dll"; + id = loader.load_plugin(missing.string().c_str()); + CHECK(id == -1, "T4.7: missing DLL returns -1"); + CHECK(g_log_call_count >= 1, + "T4.8: host_api->log called on LoadLibrary failure"); + std::cout << " log msg: " << g_last_log_msg << "\n"; + } + + // ======================================================================== + // Block 5: 边界 — 空 loader / 无效操作 + // ======================================================================== + std::cout << "\n--- Block 5: Edge cases — empty loader / invalid op ---\n"; + { + dstalk::PluginLoader loader; + + // T5.1: unload 不存在的 ID 返回 -1 + CHECK(loader.unload_plugin(42) == -1, + "T5.1: unload_plugin(nonexistent) returns -1"); + + // T5.2: 空 PluginLoader 的 list_plugins 返回 "[]" + std::string json = loader.list_plugins(); + CHECK(!json.empty(), "T5.2: list_plugins returns non-empty string"); + CHECK(json == "[]", "T5.3: empty loader produces empty JSON array"); + std::cout << " list_plugins (empty): " << json << "\n"; + + // T5.3: get_plugin 在空 loader 上返回 nullptr + CHECK(loader.get_plugin(1) == nullptr, + "T5.4: get_plugin on empty loader returns nullptr"); + } + + // ======================================================================== + // 结果 + // ======================================================================== + std::cout << "\n"; + if (g_failures == 0) { + std::cout << "=== All plugin_loader regression tests passed ===\n"; + return 0; + } else { + std::cerr << "=== " << g_failures << " test(s) FAILED ===\n"; + return 1; + } +}