W20: Tool Calling 闭环 + Stream+Tools + 回归测试 + session auto-save + ASan CI (W20.1-W20.6)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<ToolCallAccum> 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<int>(json::value_to<int64_t>(tc_obj["index"])) : -1;
|
||||
if (idx < 0) continue;
|
||||
|
||||
while (static_cast<int>(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<std::string>(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<std::string>(func["name"]);
|
||||
}
|
||||
if (func.contains("arguments") && func["arguments"].is_string()) {
|
||||
acc.arguments += json::value_to<std::string>(func["arguments"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false; // tool_calls 已处理,无内容 token 给用户回调
|
||||
}
|
||||
|
||||
if (delta.contains("content")) {
|
||||
token_out = json::value_to<std::string>(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<const dstalk_tools_service_t*>(
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user