luckfun233 commited on
Commit ·
0596a20
1
Parent(s): 147182a
refactor(prompt): 移除工具调用与技能注入的引导提示词
Browse files实测 DSML 工具调用会被上游风控识别(封号风险),且高频调用容易触发限速,
prompt 侧不再做任何引导:
- 不再生成 context_tools.txt 工具说明文件,也不再输出 DSML 调用格式要求
(You have access to these tools / TOOL CALL SCHEME / 必须调用工具);
只保留工具名提取,供 tools.enabled=true 时的响应侧 tool_calls 解析
- 技能块去掉引导语,只保留 <skill> 标签与正文,避免引导模型使用技能
- 历史拆分只上传对话记录文件;相关测试与文档同步更新
- docs/prompt-compatibility.md +17 -10
- internal/httpapi/claude/current_input_file_test.go +11 -16
- internal/httpapi/gemini/handler_test.go +10 -16
- internal/httpapi/openai/chat/vercel_prepare_test.go +20 -22
- internal/httpapi/openai/history_split_test.go +19 -27
- internal/promptcompat/prompt_build_test.go +3 -2
- internal/promptcompat/skills.go +7 -6
- internal/promptcompat/skills_test.go +4 -3
- internal/promptcompat/tool_prompt.go +25 -113
docs/prompt-compatibility.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# API → 纯文本 web-chat 上下文兼容流程
|
| 2 |
|
| 3 |
本文是本仓库「把结构化 API 请求翻译成 DeepSeek web-chat 纯文本 prompt,再把上游回答翻译回目标协议」这条链路的源文档。AGENTS.md 规定:任何影响消息归一化、工具提示词注入、提示词可见的工具历史、文件/引用处理、历史拆分、完成载荷组装的改动,都要在同一变更里更新本文。
|
| 4 |
|
|
@@ -119,11 +119,17 @@ DeepSeek web-chat 只接受一段 prompt 字符串和一组文件引用,没有
|
|
| 119 |
|
| 120 |
标题刻意不用文件名形式(历史版本是 `# context_context.txt`),避免模型把它当成一份待分析文档。旧格式里 `[r=0]`…`[r=9]` 的数字角色行不再生成;管理台读旧记录时仍能识别这种数字角色行(兼容读取),后端只把它当普通正文,不报错。
|
| 121 |
|
| 122 |
-
### 工具说明
|
| 123 |
|
| 124 |
-
工具定义单独上传为 `context_tools.txt`,
|
| 125 |
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
---
|
| 129 |
|
|
@@ -168,7 +174,7 @@ DeepSeek web-chat 只接受一段 prompt 字符串和一组文件引用,没有
|
|
| 168 |
| 配置项 | `tools.enabled`,缺省 `false`;字段缺失或值为 `null` 都按关闭处理 |
|
| 169 |
| 运行时覆盖 | `/admin/settings` 的 `tools.enabled`,热更新;GET 顶层返回 `{"enabled": bool, "default_enabled": false}` |
|
| 170 |
| 关闭时(默认) | 带 `tools` 的请求按纯对话处理:不注入任何工具说明(包括 DSML 格式要求与工具说明文件),不解析、不透出 `tool_calls`,模型输出按普通文本回写 |
|
| 171 |
-
| 开启时 | 恢复
|
| 172 |
|
| 173 |
关闭时同一请求里的 `tool_choice` 也失去意义:没有工具定义,就没有可选的调用策略。
|
| 174 |
|
|
@@ -206,16 +212,16 @@ DeepSeek web-chat 只接受一段 prompt 字符串和一组文件引用,没有
|
|
| 206 |
- 时机在历史拆分**之后**:拆分会把 prompt 整体替换成衔接指令,技能必须等替换完成后再前置,否则会被覆盖。
|
| 207 |
- 对比思考格式注入:思考格式注入在拆分**之前**,因为它需要落到对话记录文件里。两处顺序不同,改动时不要调换。
|
| 208 |
|
| 209 |
-
技能块格式(线上排查时可对照这段
|
| 210 |
|
| 211 |
```text
|
| 212 |
-
以下技能说明由服务端配置,属于内部资料:适用于当前任务时请遵循;与用户明确要求冲突时以用户为准。不要在回复里提及技能名称或这段说明。
|
| 213 |
-
|
| 214 |
<skill id="sql-report" name="SQL 报表">
|
| 215 |
…body…
|
| 216 |
</skill>
|
| 217 |
```
|
| 218 |
|
|
|
|
|
|
|
| 219 |
为什么用纯文本而不是工具调用:工具调用要求模型吐 DSML 结构化文本,正是上游风控盯的那类输出,一旦被判违规代价是账号本身;技能只是往 prompt 里加一段静态文本,模型照普通对话写回答,风控面和普通聊天没有区别。
|
| 220 |
|
| 221 |
### 泄漏防护
|
|
@@ -245,7 +251,7 @@ DeepSeek web-chat 只接受一段 prompt 字符串和一组文件引用,没有
|
|
| 245 |
满足条件时:
|
| 246 |
|
| 247 |
1. 按第 3 节的转写格式生成对话记录,写入 `ctx-<12 位小写字母数字>.txt`。文件名随机,不含 `history` 之类语义词,减少模型把它当文档分析的倾向。
|
| 248 |
-
2.
|
| 249 |
3. prompt 改写成两段:工具说明用 system 消息给出文件分工,user 消息给出衔接指令(含文件名)。原始长对话不再进 prompt。
|
| 250 |
4. 文件 ID 前置插入 `RefFileIDs`,与客户端自带的 `ref_file_ids` 去重后一起送上游。
|
| 251 |
5. `PromptTokenText` 保留「对话记录 + 工具说明」全文,用于 token 估算,与实际 prompt 长度解耦。
|
|
@@ -347,7 +353,8 @@ Kelivo 这类客户端把界面上的「思维强度」翻译成 `reasoning_effo
|
|
| 347 |
| `model_type` 只剩 `default` | 旧配置里出现 `expert` / `vision` 也不会让请求失败,解析时按 `default` 处理 |
|
| 348 |
| 上下文文件标题从 `# context_context.txt` 改为 `# 对话记录` | 旧文件是纯文本,读回不报错;新文件不再使用文件名式标题 |
|
| 349 |
| 对话记录角色行从 `[r=0]`…`[r=9]` 改为 `[role=user]` 等 | 管理台解析同时接受 `[role=...]` 与旧 `[r=N]`;后端只把记录当纯文本 |
|
| 350 |
-
| 工具说明文件
|
|
|
|
| 351 |
| 思考格式注入从 `internal/httpapi/openai/shared` 移到 `internal/promptcompat` | 四个对话接口共用同一实现,配置项与字段名不变 |
|
| 352 |
| 联网信号改为「模型默认 + 客户端显式覆盖」 | 只在客户端发显式信号时改变行为;不发信号的客户端表现与之前一致 |
|
| 353 |
| 新增 `tools.enabled`,缺省 `false` | 默认行为变化:未显式开启时不再注入工具说明、不再返回 `tool_calls`;老客户端带 `tools` 的请求会退化为纯对话,需要工具能力请在 `/admin/settings` 或配置文件显式开启 |
|
|
|
|
| 1 |
+
# API → 纯文本 web-chat 上下文兼容流程
|
| 2 |
|
| 3 |
本文是本仓库「把结构化 API 请求翻译成 DeepSeek web-chat 纯文本 prompt,再把上游回答翻译回目标协议」这条链路的源文档。AGENTS.md 规定:任何影响消息归一化、工具提示词注入、提示词可见的工具历史、文件/引用处理、历史拆分、完成载荷组装的改动,都要在同一变更里更新本文。
|
| 4 |
|
|
|
|
| 119 |
|
| 120 |
标题刻意不用文件名形式(历史版本是 `# context_context.txt`),避免模型把它当成一份待分析文档。旧格式里 `[r=0]`…`[r=9]` 的数字角色行不再生成;管理台读旧记录时仍能识别这种数字角色行(兼容读取),后端只把它当普通正文,不报错。
|
| 121 |
|
| 122 |
+
### 工具说明与调用引导(已移除)
|
| 123 |
|
| 124 |
+
历史上工具定义会单独上传为 `context_tools.txt`,并在 prompt 里补一句「可用工具与参数说明在 context_tools.txt 中」外加 DSML 调用格式要求。
|
| 125 |
|
| 126 |
+
实测这条路不可行:上游对 DSML 结构化输出风控严格,命中后账号会被封;工具调用本身高频也会撞限速。所以现在 prompt 侧**不再输出任何工具引导**:
|
| 127 |
+
|
| 128 |
+
- 不生成 `context_tools.txt`,也不会再有「可用工具说明」文件;
|
| 129 |
+
- 不输出 `You have access to these tools` / `TOOL CALL SCHEME` / 「必须调用工具」之类的说明;
|
| 130 |
+
- 只保留工具名提取,用于响应侧(客户端明确要求时)的 `tool_calls` 解析判断。
|
| 131 |
+
|
| 132 |
+
也就是说,`tools` 出现在请求里也不会让模型「知道」自己能用工具——这点是刻意为之,见第 4 节的工具调用开关。
|
| 133 |
|
| 134 |
---
|
| 135 |
|
|
|
|
| 174 |
| 配置项 | `tools.enabled`,缺省 `false`;字段缺失或值为 `null` 都按关闭处理 |
|
| 175 |
| 运行时覆盖 | `/admin/settings` 的 `tools.enabled`,热更新;GET 顶层返回 `{"enabled": bool, "default_enabled": false}` |
|
| 176 |
| 关闭时(默认) | 带 `tools` 的请求按纯对话处理:不注入任何工具说明(包括 DSML 格式要求与工具说明文件),不解析、不透出 `tool_calls`,模型输出按普通文本回写 |
|
| 177 |
+
| 开启时 | 只恢复响应侧的 `tool_calls` 解析;prompt 侧依旧不输出任何工具引导(上游风控风险不随开关消失) |
|
| 178 |
|
| 179 |
关闭时同一请求里的 `tool_choice` 也失去意义:没有工具定义,就没有可选的调用策略。
|
| 180 |
|
|
|
|
| 212 |
- 时机在历史拆分**之后**:拆分会把 prompt 整体替换成衔接指令,技能必须等替换完成后再前置,否则会被覆盖。
|
| 213 |
- 对比思考格式注入:思考格式注入在拆分**之前**,因为它需要落到对话记录文件里。两处顺序不同,改动时不要调换。
|
| 214 |
|
| 215 |
+
技能块格式(线上排查时可对照这段结构):
|
| 216 |
|
| 217 |
```text
|
|
|
|
|
|
|
| 218 |
<skill id="sql-report" name="SQL 报表">
|
| 219 |
…body…
|
| 220 |
</skill>
|
| 221 |
```
|
| 222 |
|
| 223 |
+
注意:技能块里**不再有引导语**(没有“以下是技能说明/适用于当前任务时请遵循/不要提及技能”之类前缀),只保留 `<skill>` 结构化标签与正文——避免模型被引导去“使用技能/调用能力”。
|
| 224 |
+
|
| 225 |
为什么用纯文本而不是工具调用:工具调用要求模型吐 DSML 结构化文本,正是上游风控盯的那类输出,一旦被判违规代价是账号本身;技能只是往 prompt 里加一段静态文本,模型照普通对话写回答,风控面和普通聊天没有区别。
|
| 226 |
|
| 227 |
### 泄漏防护
|
|
|
|
| 251 |
满足条件时:
|
| 252 |
|
| 253 |
1. 按第 3 节的转写格式生成对话记录,写入 `ctx-<12 位小写字母数字>.txt`。文件名随机,不含 `history` 之类语义词,减少模型把它当文档分析的倾向。
|
| 254 |
+
2. 不再额外上传工具说明文件(工具引导已移除,见第 3 节)。
|
| 255 |
3. prompt 改写成两段:工具说明用 system 消息给出文件分工,user 消息给出衔接指令(含文件名)。原始长对话不再进 prompt。
|
| 256 |
4. 文件 ID 前置插入 `RefFileIDs`,与客户端自带的 `ref_file_ids` 去重后一起送上游。
|
| 257 |
5. `PromptTokenText` 保留「对话记录 + 工具说明」全文,用于 token 估算,与实际 prompt 长度解耦。
|
|
|
|
| 353 |
| `model_type` 只剩 `default` | 旧配置里出现 `expert` / `vision` 也不会让请求失败,解析时按 `default` 处理 |
|
| 354 |
| 上下文文件标题从 `# context_context.txt` 改为 `# 对话记录` | 旧文件是纯文本,读回不报错;新文件不再使用文件名式标题 |
|
| 355 |
| 对话记录角色行从 `[r=0]`…`[r=9]` 改为 `[role=user]` 等 | 管理台解析同时接受 `[role=...]` 与旧 `[r=N]`;后端只把记录当纯文本 |
|
| 356 |
+
| 移除工具说明文件与调用引导 | 不再生成 `context_tools.txt`、不输出 DSML 格式要求;`tools` 只影响响应侧解析,取决于 `tools.enabled` |
|
| 357 |
+
| 移除技能注入的引导语 | 技能块只保留 `<skill>` 标签与正文,不再有“请遵循技能”这类前缀 |
|
| 358 |
| 思考格式注入从 `internal/httpapi/openai/shared` 移到 `internal/promptcompat` | 四个对话接口共用同一实现,配置项与字段名不变 |
|
| 359 |
| 联网信号改为「模型默认 + 客户端显式覆盖」 | 只在客户端发显式信号时改变行为;不发信号的客户端表现与之前一致 |
|
| 360 |
| 新增 `tools.enabled`,缺省 `false` | 默认行为变化:未显式开启时不再注入工具说明、不再返回 `tool_calls`;老客户端带 `tools` 的请求会退化为纯对话,需要工具能力请在 `/admin/settings` 或配置文件显式开启 |
|
internal/httpapi/claude/current_input_file_test.go
CHANGED
|
@@ -163,7 +163,7 @@ func TestClaudeDirectAppliesCurrentInputFile(t *testing.T) {
|
|
| 163 |
}
|
| 164 |
}
|
| 165 |
|
| 166 |
-
func
|
| 167 |
ds := &claudeCurrentInputDS{}
|
| 168 |
h := &Handler{
|
| 169 |
Store: mockClaudeConfig{aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}},
|
|
@@ -180,29 +180,24 @@ func TestClaudeCurrentInputFileUploadsToolsSeparately(t *testing.T) {
|
|
| 180 |
if rec.Code != http.StatusOK {
|
| 181 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 182 |
}
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
-
if
|
| 187 |
-
t.Fatalf("unexpected upload
|
| 188 |
}
|
| 189 |
historyText := string(ds.uploads[0].Data)
|
| 190 |
if strings.Contains(historyText, "You have access to these tools") || strings.Contains(historyText, "Description: Search docs") {
|
| 191 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 192 |
}
|
| 193 |
-
toolsText := string(ds.uploads[1].Data)
|
| 194 |
-
if !strings.Contains(toolsText, "# 可用工具说明") || !strings.Contains(toolsText, "Tool: search") || !strings.Contains(toolsText, "Description: Search docs") {
|
| 195 |
-
t.Fatalf("expected tools transcript to include tool schema, got %q", toolsText)
|
| 196 |
-
}
|
| 197 |
refIDs, _ := ds.payload["ref_file_ids"].([]any)
|
| 198 |
-
if len(refIDs)
|
| 199 |
-
t.Fatalf("expected history
|
| 200 |
}
|
| 201 |
prompt, _ := ds.payload["prompt"].(string)
|
| 202 |
-
if
|
| 203 |
-
t.Fatalf("expected
|
| 204 |
-
}
|
| 205 |
-
if strings.Contains(prompt, "Description: Search docs") {
|
| 206 |
-
t.Fatalf("live prompt should not inline tool descriptions, got %q", prompt)
|
| 207 |
}
|
| 208 |
}
|
|
|
|
| 163 |
}
|
| 164 |
}
|
| 165 |
|
| 166 |
+
func TestClaudeCurrentInputFileIgnoresToolGuidance(t *testing.T) {
|
| 167 |
ds := &claudeCurrentInputDS{}
|
| 168 |
h := &Handler{
|
| 169 |
Store: mockClaudeConfig{aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}},
|
|
|
|
| 180 |
if rec.Code != http.StatusOK {
|
| 181 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 182 |
}
|
| 183 |
+
// 工具调用引导(工具说明文件 + 调用格式说明)已整体移除:
|
| 184 |
+
// 只上传对话记录文件,prompt 里也不再出现任何工具相关文案。
|
| 185 |
+
if len(ds.uploads) != 1 {
|
| 186 |
+
t.Fatalf("expected only history upload, got %d", len(ds.uploads))
|
| 187 |
}
|
| 188 |
+
if !strings.HasSuffix(ds.uploads[0].Filename, ".txt") {
|
| 189 |
+
t.Fatalf("unexpected upload filename: %#v", ds.uploads)
|
| 190 |
}
|
| 191 |
historyText := string(ds.uploads[0].Data)
|
| 192 |
if strings.Contains(historyText, "You have access to these tools") || strings.Contains(historyText, "Description: Search docs") {
|
| 193 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 194 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
refIDs, _ := ds.payload["ref_file_ids"].([]any)
|
| 196 |
+
if len(refIDs) != 1 || refIDs[0] != "file-claude-history" {
|
| 197 |
+
t.Fatalf("expected only history ref id, got %#v", ds.payload["ref_file_ids"])
|
| 198 |
}
|
| 199 |
prompt, _ := ds.payload["prompt"].(string)
|
| 200 |
+
if strings.Contains(prompt, "context_tools.txt") || strings.Contains(prompt, "TOOL CALL SCHEME") || strings.Contains(prompt, "Description: Search docs") {
|
| 201 |
+
t.Fatalf("expected no tool guidance in prompt, got %q", prompt)
|
|
|
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
}
|
internal/httpapi/gemini/handler_test.go
CHANGED
|
@@ -207,7 +207,7 @@ func TestGeminiDirectAppliesCurrentInputFile(t *testing.T) {
|
|
| 207 |
}
|
| 208 |
}
|
| 209 |
|
| 210 |
-
func
|
| 211 |
ds := &testGeminiDS{
|
| 212 |
resp: makeGeminiUpstreamResponse(`data: {"p":"response/content","v":"ok"}`),
|
| 213 |
}
|
|
@@ -231,30 +231,24 @@ func TestGeminiCurrentInputFileUploadsToolsSeparately(t *testing.T) {
|
|
| 231 |
if rec.Code != http.StatusOK {
|
| 232 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 233 |
}
|
| 234 |
-
|
| 235 |
-
|
|
|
|
| 236 |
}
|
| 237 |
-
if
|
| 238 |
-
t.Fatalf("unexpected upload
|
| 239 |
}
|
| 240 |
historyText := string(ds.uploadCalls[0].Data)
|
| 241 |
if strings.Contains(historyText, "Description: eval") {
|
| 242 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 243 |
}
|
| 244 |
-
toolsText := string(ds.uploadCalls[1].Data)
|
| 245 |
-
if !strings.Contains(toolsText, "# 可用工具说明") || !strings.Contains(toolsText, "Tool: eval_javascript") || !strings.Contains(toolsText, "Description: eval") {
|
| 246 |
-
t.Fatalf("expected tools transcript to include Gemini tool schema, got %q", toolsText)
|
| 247 |
-
}
|
| 248 |
refIDs, _ := ds.payloads[0]["ref_file_ids"].([]any)
|
| 249 |
-
if len(refIDs)
|
| 250 |
-
t.Fatalf("expected history
|
| 251 |
}
|
| 252 |
prompt, _ := ds.payloads[0]["prompt"].(string)
|
| 253 |
-
if
|
| 254 |
-
t.Fatalf("expected
|
| 255 |
-
}
|
| 256 |
-
if strings.Contains(prompt, "Description: eval") {
|
| 257 |
-
t.Fatalf("live prompt should not inline tool descriptions, got %q", prompt)
|
| 258 |
}
|
| 259 |
}
|
| 260 |
|
|
|
|
| 207 |
}
|
| 208 |
}
|
| 209 |
|
| 210 |
+
func TestGeminiCurrentInputFileIgnoresToolGuidance(t *testing.T) {
|
| 211 |
ds := &testGeminiDS{
|
| 212 |
resp: makeGeminiUpstreamResponse(`data: {"p":"response/content","v":"ok"}`),
|
| 213 |
}
|
|
|
|
| 231 |
if rec.Code != http.StatusOK {
|
| 232 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 233 |
}
|
| 234 |
+
// 工具调用引导已整体移除:只上传对话记录,prompt 不再引用工具说明文件。
|
| 235 |
+
if len(ds.uploadCalls) != 1 {
|
| 236 |
+
t.Fatalf("expected only history upload, got %d", len(ds.uploadCalls))
|
| 237 |
}
|
| 238 |
+
if !strings.HasSuffix(ds.uploadCalls[0].Filename, ".txt") {
|
| 239 |
+
t.Fatalf("unexpected upload filename: %#v", ds.uploadCalls)
|
| 240 |
}
|
| 241 |
historyText := string(ds.uploadCalls[0].Data)
|
| 242 |
if strings.Contains(historyText, "Description: eval") {
|
| 243 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 244 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
refIDs, _ := ds.payloads[0]["ref_file_ids"].([]any)
|
| 246 |
+
if len(refIDs) != 1 || refIDs[0] != "file-gemini-history" {
|
| 247 |
+
t.Fatalf("expected only history ref id, got %#v", ds.payloads[0]["ref_file_ids"])
|
| 248 |
}
|
| 249 |
prompt, _ := ds.payloads[0]["prompt"].(string)
|
| 250 |
+
if strings.Contains(prompt, "context_tools.txt") || strings.Contains(prompt, "TOOL CALL SCHEME") || strings.Contains(prompt, "Description: eval") {
|
| 251 |
+
t.Fatalf("expected no tool guidance in prompt, got %q", prompt)
|
|
|
|
|
|
|
|
|
|
| 252 |
}
|
| 253 |
}
|
| 254 |
|
internal/httpapi/openai/chat/vercel_prepare_test.go
CHANGED
|
@@ -324,7 +324,7 @@ func TestHandleVercelStreamReleaseTriggersAutoDelete(t *testing.T) {
|
|
| 324 |
}
|
| 325 |
}
|
| 326 |
|
| 327 |
-
func
|
| 328 |
t.Setenv("VERCEL", "1")
|
| 329 |
t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret")
|
| 330 |
|
|
@@ -363,11 +363,12 @@ func TestHandleVercelStreamPrepareUploadsToolsSeparately(t *testing.T) {
|
|
| 363 |
if rec.Code != http.StatusOK {
|
| 364 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 365 |
}
|
| 366 |
-
|
| 367 |
-
|
|
|
|
| 368 |
}
|
| 369 |
-
if
|
| 370 |
-
t.Fatalf("unexpected upload
|
| 371 |
}
|
| 372 |
if strings.Contains(string(ds.uploadCalls[0].Data), "Description: search docs") {
|
| 373 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", string(ds.uploadCalls[0].Data))
|
|
@@ -381,16 +382,13 @@ func TestHandleVercelStreamPrepareUploadsToolsSeparately(t *testing.T) {
|
|
| 381 |
payload, _ := body["payload"].(map[string]any)
|
| 382 |
payloadPrompt, _ := payload["prompt"].(string)
|
| 383 |
for label, promptText := range map[string]string{"final_prompt": finalPrompt, "payload.prompt": payloadPrompt} {
|
| 384 |
-
if
|
| 385 |
-
t.Fatalf("expected %s to
|
| 386 |
-
}
|
| 387 |
-
if strings.Contains(promptText, "Description: search docs") {
|
| 388 |
-
t.Fatalf("expected %s not to inline tool descriptions, got %q", label, promptText)
|
| 389 |
}
|
| 390 |
}
|
| 391 |
refIDs, _ := payload["ref_file_ids"].([]any)
|
| 392 |
-
if len(refIDs)
|
| 393 |
-
t.Fatalf("expected history
|
| 394 |
}
|
| 395 |
}
|
| 396 |
|
|
@@ -463,9 +461,9 @@ func TestHandleVercelStreamSwitchReuploadsCurrentInputFile(t *testing.T) {
|
|
| 463 |
ResolvedModel: "deepseek-v4-flash",
|
| 464 |
ResponseModel: "deepseek-v4-flash",
|
| 465 |
CurrentInputFilename: "ctx-fixed-1234.txt",
|
| 466 |
-
FinalPrompt: "已附
|
| 467 |
-
PromptTokenText: "#
|
| 468 |
-
HistoryText: "#
|
| 469 |
CurrentInputFileApplied: true,
|
| 470 |
CurrentInputFileID: "file-old",
|
| 471 |
CurrentToolsFileID: "file-old-tools",
|
|
@@ -492,11 +490,11 @@ func TestHandleVercelStreamSwitchReuploadsCurrentInputFile(t *testing.T) {
|
|
| 492 |
if rec.Code != http.StatusOK {
|
| 493 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 494 |
}
|
| 495 |
-
if len(ds.uploadCalls) !=
|
| 496 |
-
t.Fatalf("expected current input
|
| 497 |
}
|
| 498 |
-
if
|
| 499 |
-
t.Fatalf("unexpected reupload
|
| 500 |
}
|
| 501 |
var body map[string]any
|
| 502 |
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
@@ -507,11 +505,11 @@ func TestHandleVercelStreamSwitchReuploadsCurrentInputFile(t *testing.T) {
|
|
| 507 |
}
|
| 508 |
payload, _ := body["payload"].(map[string]any)
|
| 509 |
refIDs, _ := payload["ref_file_ids"].([]any)
|
| 510 |
-
if len(refIDs) !=
|
| 511 |
t.Fatalf("expected reuploaded current input ref plus client ref, got %#v", payload["ref_file_ids"])
|
| 512 |
}
|
| 513 |
promptText, _ := payload["prompt"].(string)
|
| 514 |
-
if
|
| 515 |
-
t.Fatalf("expected switched payload prompt to
|
| 516 |
}
|
| 517 |
}
|
|
|
|
| 324 |
}
|
| 325 |
}
|
| 326 |
|
| 327 |
+
func TestHandleVercelStreamPrepareIgnoresToolGuidance(t *testing.T) {
|
| 328 |
t.Setenv("VERCEL", "1")
|
| 329 |
t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret")
|
| 330 |
|
|
|
|
| 363 |
if rec.Code != http.StatusOK {
|
| 364 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 365 |
}
|
| 366 |
+
// 工具调用引导已整体移除:只上传对话记录,不再有 context_tools.txt。
|
| 367 |
+
if len(ds.uploadCalls) != 1 {
|
| 368 |
+
t.Fatalf("expected only history upload, got %d", len(ds.uploadCalls))
|
| 369 |
}
|
| 370 |
+
if !strings.HasSuffix(ds.uploadCalls[0].Filename, ".txt") {
|
| 371 |
+
t.Fatalf("unexpected upload filename: %#v", ds.uploadCalls)
|
| 372 |
}
|
| 373 |
if strings.Contains(string(ds.uploadCalls[0].Data), "Description: search docs") {
|
| 374 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", string(ds.uploadCalls[0].Data))
|
|
|
|
| 382 |
payload, _ := body["payload"].(map[string]any)
|
| 383 |
payloadPrompt, _ := payload["prompt"].(string)
|
| 384 |
for label, promptText := range map[string]string{"final_prompt": finalPrompt, "payload.prompt": payloadPrompt} {
|
| 385 |
+
if strings.Contains(promptText, "context_tools.txt") || strings.Contains(promptText, "TOOL CALL SCHEME") || strings.Contains(promptText, "Description: search docs") {
|
| 386 |
+
t.Fatalf("expected %s to carry no tool guidance, got %q", label, promptText)
|
|
|
|
|
|
|
|
|
|
| 387 |
}
|
| 388 |
}
|
| 389 |
refIDs, _ := payload["ref_file_ids"].([]any)
|
| 390 |
+
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
|
| 391 |
+
t.Fatalf("expected only history ref id, got %#v", payload["ref_file_ids"])
|
| 392 |
}
|
| 393 |
}
|
| 394 |
|
|
|
|
| 461 |
ResolvedModel: "deepseek-v4-flash",
|
| 462 |
ResponseModel: "deepseek-v4-flash",
|
| 463 |
CurrentInputFilename: "ctx-fixed-1234.txt",
|
| 464 |
+
FinalPrompt: "本会话此前的对话已完整保存在附件「ctx-fixed-1234.txt」中。请把它当成你自己刚刚说过的话,直接回答最新一条用户消息。",
|
| 465 |
+
PromptTokenText: "# 对话记录\n\n=== 1 ===\n[role=user]\nhello\n",
|
| 466 |
+
HistoryText: "# 对话记录\n\n=== 1 ===\n[role=user]\nhello\n",
|
| 467 |
CurrentInputFileApplied: true,
|
| 468 |
CurrentInputFileID: "file-old",
|
| 469 |
CurrentToolsFileID: "file-old-tools",
|
|
|
|
| 490 |
if rec.Code != http.StatusOK {
|
| 491 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 492 |
}
|
| 493 |
+
if len(ds.uploadCalls) != 1 {
|
| 494 |
+
t.Fatalf("expected only current input reupload on switched account, got %d", len(ds.uploadCalls))
|
| 495 |
}
|
| 496 |
+
if !strings.HasSuffix(ds.uploadCalls[0].Filename, ".txt") {
|
| 497 |
+
t.Fatalf("unexpected reupload filename: %#v", ds.uploadCalls)
|
| 498 |
}
|
| 499 |
var body map[string]any
|
| 500 |
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
|
|
| 505 |
}
|
| 506 |
payload, _ := body["payload"].(map[string]any)
|
| 507 |
refIDs, _ := payload["ref_file_ids"].([]any)
|
| 508 |
+
if len(refIDs) != 2 || refIDs[0] != "file-inline-1" || refIDs[1] != "client-file" {
|
| 509 |
t.Fatalf("expected reuploaded current input ref plus client ref, got %#v", payload["ref_file_ids"])
|
| 510 |
}
|
| 511 |
promptText, _ := payload["prompt"].(string)
|
| 512 |
+
if strings.Contains(promptText, "context_tools.txt") {
|
| 513 |
+
t.Fatalf("expected switched payload prompt to carry no tool guidance, got %q", promptText)
|
| 514 |
}
|
| 515 |
}
|
internal/httpapi/openai/history_split_test.go
CHANGED
|
@@ -335,7 +335,7 @@ func TestApplyCurrentInputFileSplitsLegacyProAndVisionModels(t *testing.T) {
|
|
| 335 |
}
|
| 336 |
}
|
| 337 |
|
| 338 |
-
func
|
| 339 |
ds := &inlineUploadDSStub{}
|
| 340 |
h := &openAITestSurface{
|
| 341 |
Store: mockOpenAIConfig{
|
|
@@ -369,22 +369,20 @@ func TestApplyCurrentInputFileUploadsToolsContextSeparately(t *testing.T) {
|
|
| 369 |
if err != nil {
|
| 370 |
t.Fatalf("apply current input file failed: %v", err)
|
| 371 |
}
|
| 372 |
-
|
| 373 |
-
|
|
|
|
|
|
|
| 374 |
}
|
| 375 |
-
if
|
| 376 |
-
t.Fatalf("unexpected upload
|
| 377 |
}
|
| 378 |
historyText := string(ds.uploadCalls[0].Data)
|
| 379 |
if strings.Contains(historyText, "Description: search docs") {
|
| 380 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 381 |
}
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
t.Fatalf("expected tools transcript to include schema, got %q", toolsText)
|
| 385 |
-
}
|
| 386 |
-
if !strings.Contains(out.FinalPrompt, "context_tools.txt") || !strings.Contains(out.FinalPrompt, "TOOL CALL SCHEME") {
|
| 387 |
-
t.Fatalf("expected prompt to reference tools file and include tool instructions, got %q", out.FinalPrompt)
|
| 388 |
}
|
| 389 |
}
|
| 390 |
|
|
@@ -545,7 +543,7 @@ func TestResponsesCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing
|
|
| 545 |
}
|
| 546 |
}
|
| 547 |
|
| 548 |
-
func
|
| 549 |
ds := &inlineUploadDSStub{}
|
| 550 |
h := &openAITestSurface{
|
| 551 |
Store: mockOpenAIConfig{
|
|
@@ -581,30 +579,24 @@ func TestResponsesCurrentInputFileUploadsToolsSeparately(t *testing.T) {
|
|
| 581 |
if rec.Code != http.StatusOK {
|
| 582 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 583 |
}
|
| 584 |
-
|
| 585 |
-
|
|
|
|
| 586 |
}
|
| 587 |
-
if
|
| 588 |
-
t.Fatalf("unexpected upload
|
| 589 |
}
|
| 590 |
historyText := string(ds.uploadCalls[0].Data)
|
| 591 |
if strings.Contains(historyText, "Description: search docs") {
|
| 592 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 593 |
}
|
| 594 |
-
toolsText := string(ds.uploadCalls[1].Data)
|
| 595 |
-
if !strings.Contains(toolsText, "# 可用工具说明") || !strings.Contains(toolsText, "Tool: search") || !strings.Contains(toolsText, "Description: search docs") {
|
| 596 |
-
t.Fatalf("expected tools transcript to include schema, got %q", toolsText)
|
| 597 |
-
}
|
| 598 |
promptText, _ := ds.completionReq["prompt"].(string)
|
| 599 |
-
if
|
| 600 |
-
t.Fatalf("expected
|
| 601 |
-
}
|
| 602 |
-
if strings.Contains(promptText, "Description: search docs") {
|
| 603 |
-
t.Fatalf("live prompt should not inline tool descriptions, got %q", promptText)
|
| 604 |
}
|
| 605 |
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
|
| 606 |
-
if len(refIDs)
|
| 607 |
-
t.Fatalf("expected history
|
| 608 |
}
|
| 609 |
}
|
| 610 |
|
|
|
|
| 335 |
}
|
| 336 |
}
|
| 337 |
|
| 338 |
+
func TestApplyCurrentInputFileIgnoresToolGuidance(t *testing.T) {
|
| 339 |
ds := &inlineUploadDSStub{}
|
| 340 |
h := &openAITestSurface{
|
| 341 |
Store: mockOpenAIConfig{
|
|
|
|
| 369 |
if err != nil {
|
| 370 |
t.Fatalf("apply current input file failed: %v", err)
|
| 371 |
}
|
| 372 |
+
// 工具调用引导已整体移除:只有对话记录文件,没有工具说明文件,
|
| 373 |
+
// prompt 里也不再出现任何工具相关文案。
|
| 374 |
+
if len(ds.uploadCalls) != 1 {
|
| 375 |
+
t.Fatalf("expected only history upload, got %d", len(ds.uploadCalls))
|
| 376 |
}
|
| 377 |
+
if !strings.HasSuffix(ds.uploadCalls[0].Filename, ".txt") {
|
| 378 |
+
t.Fatalf("unexpected upload filename: %#v", ds.uploadCalls)
|
| 379 |
}
|
| 380 |
historyText := string(ds.uploadCalls[0].Data)
|
| 381 |
if strings.Contains(historyText, "Description: search docs") {
|
| 382 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 383 |
}
|
| 384 |
+
if strings.Contains(out.FinalPrompt, "context_tools.txt") || strings.Contains(out.FinalPrompt, "TOOL CALL SCHEME") {
|
| 385 |
+
t.Fatalf("expected no tool guidance in prompt, got %q", out.FinalPrompt)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}
|
| 387 |
}
|
| 388 |
|
|
|
|
| 543 |
}
|
| 544 |
}
|
| 545 |
|
| 546 |
+
func TestResponsesCurrentInputFileIgnoresToolGuidance(t *testing.T) {
|
| 547 |
ds := &inlineUploadDSStub{}
|
| 548 |
h := &openAITestSurface{
|
| 549 |
Store: mockOpenAIConfig{
|
|
|
|
| 579 |
if rec.Code != http.StatusOK {
|
| 580 |
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
| 581 |
}
|
| 582 |
+
// 工具调用引导已整体移除:只上传对话记录,prompt 不再引用工具。
|
| 583 |
+
if len(ds.uploadCalls) != 1 {
|
| 584 |
+
t.Fatalf("expected only history upload, got %d", len(ds.uploadCalls))
|
| 585 |
}
|
| 586 |
+
if !strings.HasSuffix(ds.uploadCalls[0].Filename, ".txt") {
|
| 587 |
+
t.Fatalf("unexpected upload filename: %#v", ds.uploadCalls)
|
| 588 |
}
|
| 589 |
historyText := string(ds.uploadCalls[0].Data)
|
| 590 |
if strings.Contains(historyText, "Description: search docs") {
|
| 591 |
t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText)
|
| 592 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
promptText, _ := ds.completionReq["prompt"].(string)
|
| 594 |
+
if strings.Contains(promptText, "context_tools.txt") || strings.Contains(promptText, "TOOL CALL SCHEME") || strings.Contains(promptText, "Description: search docs") {
|
| 595 |
+
t.Fatalf("expected no tool guidance in prompt, got %q", promptText)
|
|
|
|
|
|
|
|
|
|
| 596 |
}
|
| 597 |
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
|
| 598 |
+
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
|
| 599 |
+
t.Fatalf("expected only history ref id, got %#v", ds.completionReq["ref_file_ids"])
|
| 600 |
}
|
| 601 |
}
|
| 602 |
|
internal/promptcompat/prompt_build_test.go
CHANGED
|
@@ -124,8 +124,9 @@ func TestBuildOpenAIToolsContextTranscriptContainsOnlyDescriptions(t *testing.T)
|
|
| 124 |
if len(toolNames) != 1 || toolNames[0] != "search" {
|
| 125 |
t.Fatalf("unexpected tool names: %#v", toolNames)
|
| 126 |
}
|
| 127 |
-
|
| 128 |
-
|
|
|
|
| 129 |
}
|
| 130 |
}
|
| 131 |
|
|
|
|
| 124 |
if len(toolNames) != 1 || toolNames[0] != "search" {
|
| 125 |
t.Fatalf("unexpected tool names: %#v", toolNames)
|
| 126 |
}
|
| 127 |
+
// 工具说明与调用引导已移除:不再生成工具说明文件,只保留工具名。
|
| 128 |
+
if transcript != "" {
|
| 129 |
+
t.Fatalf("expected empty tools transcript, got: %q", transcript)
|
| 130 |
}
|
| 131 |
}
|
| 132 |
|
internal/promptcompat/skills.go
CHANGED
|
@@ -11,14 +11,15 @@ import (
|
|
| 11 |
// 避免多一层转换。
|
| 12 |
type SkillConfigLike = config.SkillConfig
|
| 13 |
|
| 14 |
-
// SkillsPromptHeader 是技能块开头的说明。它只在 prompt 里出现,
|
| 15 |
-
// 用户与模型都不应该把它当成对话内容复述出来。
|
| 16 |
-
const SkillsPromptHeader = "以下技能说明由服务端配置,属于内部资料:适用于当前任务时请遵循;与用户明确要求冲突时以用户为准。不要在回复里提及技能名称或这段说明。"
|
| 17 |
-
|
| 18 |
// SkillsPromptTruncatedNotice 在正文被长度上限截断时追加。
|
| 19 |
const SkillsPromptTruncatedNotice = "(技能内容已截断)"
|
| 20 |
|
| 21 |
-
// BuildSkillsPrompt 生成
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
// maxChars 是整块注入的字符上限(按 rune 计),0 或负数表示不限制。
|
| 23 |
func BuildSkillsPrompt(items []SkillConfigLike, maxChars int) string {
|
| 24 |
blocks := make([]string, 0, len(items))
|
|
@@ -39,7 +40,7 @@ func BuildSkillsPrompt(items []SkillConfigLike, maxChars int) string {
|
|
| 39 |
if len(blocks) == 0 {
|
| 40 |
return ""
|
| 41 |
}
|
| 42 |
-
block :=
|
| 43 |
if maxChars > 0 {
|
| 44 |
block = truncateRunes(block, maxChars)
|
| 45 |
}
|
|
|
|
| 11 |
// 避免多一层转换。
|
| 12 |
type SkillConfigLike = config.SkillConfig
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
// SkillsPromptTruncatedNotice 在正文被长度上限截断时追加。
|
| 15 |
const SkillsPromptTruncatedNotice = "(技能内容已截断)"
|
| 16 |
|
| 17 |
+
// BuildSkillsPrompt 生成技能正文块;无可用技能时返回空串。
|
| 18 |
+
//
|
| 19 |
+
// 这里刻意不再输出「以下是技能说明 / 适用于当前任务时请遵循 / 不要提及技能」
|
| 20 |
+
// 之类引导文案:技能只是运营方提供的参考资料,直接作为正文注入即可,
|
| 21 |
+
// 避免模型被引导去“使用技能/调用能力”。
|
| 22 |
+
//
|
| 23 |
// maxChars 是整块注入的字符上限(按 rune 计),0 或负数表示不限制。
|
| 24 |
func BuildSkillsPrompt(items []SkillConfigLike, maxChars int) string {
|
| 25 |
blocks := make([]string, 0, len(items))
|
|
|
|
| 40 |
if len(blocks) == 0 {
|
| 41 |
return ""
|
| 42 |
}
|
| 43 |
+
block := strings.Join(blocks, "\n\n")
|
| 44 |
if maxChars > 0 {
|
| 45 |
block = truncateRunes(block, maxChars)
|
| 46 |
}
|
internal/promptcompat/skills_test.go
CHANGED
|
@@ -26,8 +26,9 @@ func TestBuildSkillsPromptFormat(t *testing.T) {
|
|
| 26 |
{ID: "sql-report", Name: "SQL 报表", Description: "按固定口径写 SQL", Body: "# 说明\n先对齐口径"},
|
| 27 |
}
|
| 28 |
block := BuildSkillsPrompt(items, 0)
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 31 |
}
|
| 32 |
if !strings.Contains(block, `<skill id="sql-report" name="SQL 报表">`) {
|
| 33 |
t.Fatalf("expected skill open tag, got %q", block)
|
|
@@ -117,7 +118,7 @@ func TestApplySkillsToRequestIsIdempotent(t *testing.T) {
|
|
| 117 |
block := BuildSkillsPrompt([]SkillConfigLike{{ID: "a", Body: "body"}}, 0)
|
| 118 |
once := ApplySkillsToRequest(stdReq, block)
|
| 119 |
twice := ApplySkillsToRequest(once, block)
|
| 120 |
-
if strings.Count(twice.FinalPrompt,
|
| 121 |
t.Fatalf("expected single injection, got %q", twice.FinalPrompt)
|
| 122 |
}
|
| 123 |
}
|
|
|
|
| 26 |
{ID: "sql-report", Name: "SQL 报表", Description: "按固定口径写 SQL", Body: "# 说明\n先对齐口径"},
|
| 27 |
}
|
| 28 |
block := BuildSkillsPrompt(items, 0)
|
| 29 |
+
// 引导文案已移除:块里只应出现技能正文与结构化标签。
|
| 30 |
+
if strings.Contains(block, "适用于当前任务时请遵循") {
|
| 31 |
+
t.Fatalf("expected no skill guidance header, got %q", block)
|
| 32 |
}
|
| 33 |
if !strings.Contains(block, `<skill id="sql-report" name="SQL 报表">`) {
|
| 34 |
t.Fatalf("expected skill open tag, got %q", block)
|
|
|
|
| 118 |
block := BuildSkillsPrompt([]SkillConfigLike{{ID: "a", Body: "body"}}, 0)
|
| 119 |
once := ApplySkillsToRequest(stdReq, block)
|
| 120 |
twice := ApplySkillsToRequest(once, block)
|
| 121 |
+
if strings.Count(twice.FinalPrompt, `<skill id="a">`) != 1 {
|
| 122 |
t.Fatalf("expected single injection, got %q", twice.FinalPrompt)
|
| 123 |
}
|
| 124 |
}
|
internal/promptcompat/tool_prompt.go
CHANGED
|
@@ -1,119 +1,51 @@
|
|
| 1 |
package promptcompat
|
| 2 |
|
| 3 |
import (
|
| 4 |
-
"encoding/json"
|
| 5 |
-
"fmt"
|
| 6 |
"strings"
|
| 7 |
-
"unicode"
|
| 8 |
|
| 9 |
"ds2api/internal/toolcall"
|
| 10 |
)
|
| 11 |
|
|
|
|
|
|
|
| 12 |
const CurrentToolsContextFilename = "context_tools.txt"
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoicePolicy) ([]map[string]any, []string) {
|
| 24 |
-
return injectToolPromptWithDescriptions(messages, tools, policy, true)
|
| 25 |
}
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
-
|
|
|
|
| 32 |
return messages, extractToolNames(tools, policy)
|
| 33 |
}
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
if strings.TrimSpace(name) == "" {
|
| 40 |
-
return false
|
| 41 |
-
}
|
| 42 |
-
if len(policy.Allowed) == 0 {
|
| 43 |
-
return true
|
| 44 |
-
}
|
| 45 |
-
_, ok := policy.Allowed[name]
|
| 46 |
-
return ok
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
for _, t := range tools {
|
| 50 |
-
tool, ok := t.(map[string]any)
|
| 51 |
-
if !ok {
|
| 52 |
-
continue
|
| 53 |
-
}
|
| 54 |
-
name, desc, schema := toolcall.ExtractToolMeta(tool)
|
| 55 |
-
name = strings.TrimSpace(name)
|
| 56 |
-
if !isAllowed(name) {
|
| 57 |
-
continue
|
| 58 |
-
}
|
| 59 |
-
names = append(names, name)
|
| 60 |
-
if desc == "" {
|
| 61 |
-
desc = "No description available"
|
| 62 |
-
}
|
| 63 |
-
b, _ := json.Marshal(schema)
|
| 64 |
-
toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, string(b)))
|
| 65 |
-
}
|
| 66 |
-
if len(toolSchemas) == 0 {
|
| 67 |
-
return toolPromptParts{Names: names}
|
| 68 |
-
}
|
| 69 |
-
descriptions := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n")
|
| 70 |
-
instructions := toolcall.BuildToolCallInstructions(names)
|
| 71 |
-
if hasReadLikeTool(names) {
|
| 72 |
-
instructions += "\n\nRead-tool cache guard: If a Read/read_file-style tool result says the file is unchanged, already available in history, should be referenced from previous context, or otherwise provides no file body, treat that result as missing content. Do not repeatedly call the same read request for that missing body. Request a full-content read if the tool supports it, or tell the user that the file contents need to be provided again."
|
| 73 |
-
}
|
| 74 |
-
if policy.Mode == ToolChoiceRequired {
|
| 75 |
-
instructions += "\n7) For this response, you MUST call at least one tool from the allowed list."
|
| 76 |
-
}
|
| 77 |
-
if policy.Mode == ToolChoiceForced && strings.TrimSpace(policy.ForcedName) != "" {
|
| 78 |
-
instructions += "\n7) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName)
|
| 79 |
-
instructions += "\n8) Do not call any other tool."
|
| 80 |
-
}
|
| 81 |
-
return toolPromptParts{
|
| 82 |
-
Descriptions: descriptions,
|
| 83 |
-
Instructions: instructions,
|
| 84 |
-
Names: names,
|
| 85 |
-
}
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
func BuildOpenAIToolsContextTranscript(toolsRaw any, policy ToolChoicePolicy) (string, []string) {
|
| 89 |
-
tools, ok := toolsRaw.([]any)
|
| 90 |
-
if !ok || len(tools) == 0 || policy.IsNone() {
|
| 91 |
-
return "", nil
|
| 92 |
-
}
|
| 93 |
-
parts := buildToolPromptParts(tools, policy)
|
| 94 |
-
if strings.TrimSpace(parts.Descriptions) == "" {
|
| 95 |
-
return "", parts.Names
|
| 96 |
-
}
|
| 97 |
-
var b strings.Builder
|
| 98 |
-
b.WriteString(toolsTranscriptTitle)
|
| 99 |
-
b.WriteString("\n")
|
| 100 |
-
b.WriteString(toolsTranscriptSummary)
|
| 101 |
-
b.WriteString("\n\n")
|
| 102 |
-
b.WriteString(strings.TrimSpace(parts.Descriptions))
|
| 103 |
-
b.WriteString("\n")
|
| 104 |
-
return b.String(), parts.Names
|
| 105 |
}
|
| 106 |
|
| 107 |
-
func
|
| 108 |
tools, ok := toolsRaw.([]any)
|
| 109 |
if !ok || len(tools) == 0 || policy.IsNone() {
|
| 110 |
-
return
|
| 111 |
-
}
|
| 112 |
-
parts := buildToolPromptParts(tools, policy)
|
| 113 |
-
if strings.TrimSpace(parts.Instructions) == "" {
|
| 114 |
-
return "", parts.Names
|
| 115 |
}
|
| 116 |
-
return
|
| 117 |
}
|
| 118 |
|
| 119 |
func extractToolNames(tools []any, policy ToolChoicePolicy) []string {
|
|
@@ -138,23 +70,3 @@ func extractToolNames(tools []any, policy ToolChoicePolicy) []string {
|
|
| 138 |
}
|
| 139 |
return names
|
| 140 |
}
|
| 141 |
-
|
| 142 |
-
func hasReadLikeTool(names []string) bool {
|
| 143 |
-
for _, name := range names {
|
| 144 |
-
switch normalizeToolNameForGuard(name) {
|
| 145 |
-
case "read", "readfile":
|
| 146 |
-
return true
|
| 147 |
-
}
|
| 148 |
-
}
|
| 149 |
-
return false
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
func normalizeToolNameForGuard(name string) string {
|
| 153 |
-
var b strings.Builder
|
| 154 |
-
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
|
| 155 |
-
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
| 156 |
-
b.WriteRune(r)
|
| 157 |
-
}
|
| 158 |
-
}
|
| 159 |
-
return b.String()
|
| 160 |
-
}
|
|
|
|
| 1 |
package promptcompat
|
| 2 |
|
| 3 |
import (
|
|
|
|
|
|
|
| 4 |
"strings"
|
|
|
|
| 5 |
|
| 6 |
"ds2api/internal/toolcall"
|
| 7 |
)
|
| 8 |
|
| 9 |
+
// CurrentToolsContextFilename 保留历史文件名常量:即使当前不再往 prompt 注入
|
| 10 |
+
// 工具说明,旧的对话记录里仍可能出现这个名字。
|
| 11 |
const CurrentToolsContextFilename = "context_tools.txt"
|
| 12 |
|
| 13 |
+
// 工具调用在上游(DeepSeek)会被风控识别,而且高频调用很容易触发限速,
|
| 14 |
+
// 实测不可行。因此 prompt 侧不再输出任何「可用工具 / 调用格式 / 必须调用」
|
| 15 |
+
// 之类的引导文案:既不生成工具说明文件,也不生成调用格式说明。
|
| 16 |
+
//
|
| 17 |
+
// 这里仍然保留工具名提取,因为响应侧的 tool_call 解析(客户端明确要求时)
|
| 18 |
+
// 需要知道本轮声明了哪些工具;prompt 里则完全不提。
|
| 19 |
|
| 20 |
+
// BuildOpenAIToolsContextTranscript 现在恒返回空串(不再生成工具说明文件),
|
| 21 |
+
// 只返回提取到的工具名,供上层判断可用工具集合。
|
| 22 |
+
func BuildOpenAIToolsContextTranscript(toolsRaw any, policy ToolChoicePolicy) (string, []string) {
|
| 23 |
+
return "", toolNamesForPolicy(toolsRaw, policy)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
+
// BuildOpenAIToolPromptInstructions 现在恒返回空串(不再注入工具调用引导),
|
| 27 |
+
// 只返回提取到的工具名。
|
| 28 |
+
func BuildOpenAIToolPromptInstructions(toolsRaw any, policy ToolChoicePolicy) (string, []string) {
|
| 29 |
+
return "", toolNamesForPolicy(toolsRaw, policy)
|
| 30 |
}
|
| 31 |
|
| 32 |
+
// injectToolPrompt 不再向消息里注入任何工具引导,只回收工具名。
|
| 33 |
+
func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoicePolicy) ([]map[string]any, []string) {
|
| 34 |
return messages, extractToolNames(tools, policy)
|
| 35 |
}
|
| 36 |
|
| 37 |
+
// injectToolPromptInstructionsOnly 与 injectToolPrompt 行为一致:
|
| 38 |
+
// 工具说明与调用引导都已移除,两者不再有区别。
|
| 39 |
+
func injectToolPromptInstructionsOnly(messages []map[string]any, tools []any, policy ToolChoicePolicy) ([]map[string]any, []string) {
|
| 40 |
+
return messages, extractToolNames(tools, policy)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
+
func toolNamesForPolicy(toolsRaw any, policy ToolChoicePolicy) []string {
|
| 44 |
tools, ok := toolsRaw.([]any)
|
| 45 |
if !ok || len(tools) == 0 || policy.IsNone() {
|
| 46 |
+
return nil
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
+
return extractToolNames(tools, policy)
|
| 49 |
}
|
| 50 |
|
| 51 |
func extractToolNames(tools []any, policy ToolChoicePolicy) []string {
|
|
|
|
| 70 |
}
|
| 71 |
return names
|
| 72 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|