From fc271e494145ac96c79142d1b0f7094665afcc14 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 8 Sep 2026 18:10:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(deps):=20=E5=8D=87=E7=BA=A7=20Twilight?= =?UTF-8?q?=20=E4=BB=A5=E4=BF=AE=E5=A4=8D=20DeepSeek=20=E6=80=9D=E8=80=83?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E7=BB=AD=E8=B7=91=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek 思考模式在请求携带 tools 时校验最后一条 user 消息之后的每条 assistant tool-call 消息:缺少 reasoning_content 键返回 400 "The reasoning_content in the thinking mode must be passed back to the API", 空字符串可通过。运行中的步骤由 DeepSeek 服务端按 tool_call id 补齐, 因此只有 ask_user / 工具审批续跑回放持久化历史时触发。 Twilight 主线 18a9879(felinics/twilight #48)在 DeepSeek / Kimi compat 下 为缺少 reasoning part 的 tool-call assistant 消息补 reasoning_content: "", 并把响应中的空 reasoning_content 记录为 reasoning block。Memoh 已按官方 base URL 或 chat_completions_compat 配置解析 DeepSeek compat,因此本仓库 只需升级依赖:3e7b614 -> 18a9879。 验证:通过 NewSDKChatModel 构造 deepseek-v4-flash 模型,回放 "assistant tool-call(无 reasoning)→ tool result" 历史并携带 tools 发起 请求。旧版 Twilight 下 DoGenerate 与 DoStream 均返回上述 400;修复版 两者均返回 200 并生成回答。 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1c09d44913..fb718c0504 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/felinics/acgo v0.0.0-20260829152557-fc78bf271ef8 github.com/felinics/connect-it/sdk/go v0.1.1-0.20260829153217-0dcd18de667d github.com/felinics/dingtalk-stream-sdk-go v0.0.0-20260829152622-ce4b7ea674a5 - github.com/felinics/twilight v0.6.1-0.20260829152448-3e7b6144320c + github.com/felinics/twilight v0.6.1-0.20260908100548-18a9879d43ba github.com/go-ego/gse v1.0.2 github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/go.sum b/go.sum index ffeea59c12..bd9580485e 100644 --- a/go.sum +++ b/go.sum @@ -237,8 +237,8 @@ github.com/felinics/connect-it/sdk/go v0.1.1-0.20260829153217-0dcd18de667d h1:4x github.com/felinics/connect-it/sdk/go v0.1.1-0.20260829153217-0dcd18de667d/go.mod h1:efQBEl/nBI7s7ptKhxpMIQWXMFwJA/mQlG8Y5Gx7y1k= github.com/felinics/dingtalk-stream-sdk-go v0.0.0-20260829152622-ce4b7ea674a5 h1:f70CJWZc4i4csjDQ50PpDVVqyc3wBFgRyGzKc/G101Q= github.com/felinics/dingtalk-stream-sdk-go v0.0.0-20260829152622-ce4b7ea674a5/go.mod h1:6lA5cvlc9Grmj5AgIMI0tCKB3o3M49qHzeA3rxmHZ1o= -github.com/felinics/twilight v0.6.1-0.20260829152448-3e7b6144320c h1:R9Y6Hz04qAkhhutqUM0mkFsEGn5zYb2rQdbKU6kPuBw= -github.com/felinics/twilight v0.6.1-0.20260829152448-3e7b6144320c/go.mod h1:ccjd58F/NP7/IUvePMhlYJRigJpIt635j4EFjNsSAAk= +github.com/felinics/twilight v0.6.1-0.20260908100548-18a9879d43ba h1:ambJktXDrDwL8yt3XCejpLa6MI2YlS/6Tf1e7H0wLwk= +github.com/felinics/twilight v0.6.1-0.20260908100548-18a9879d43ba/go.mod h1:ccjd58F/NP7/IUvePMhlYJRigJpIt635j4EFjNsSAAk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= From 23f6a0327011de5253bd68e3062309caec205771 Mon Sep 17 00:00:00 2001 From: Fodesu Date: Tue, 8 Sep 2026 18:10:18 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(context):=20=E7=BB=AD=E8=B7=91=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=E4=BF=9D=E7=95=99=E6=9C=AA=E7=BB=93=E6=9D=9F=E8=BD=AE?= =?UTF-8?q?=E6=AC=A1=E7=9A=84=E5=B7=A5=E5=85=B7=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工具审批 / ask_user 续跑的请求不携带当前用户消息,持久化历史整体落入 history 槽位,工具交互裁剪策略(消息数超过阈值时生效)会剥掉挂起步骤的 tool call、tool result 和 reasoning。结果有两个:模型看不到工具结果, 续跑后重新发起同一个工具调用;请求以一条没有 reasoning_content 的 assistant 文本消息结尾,DeepSeek 思考模式返回 400 "The reasoning_content in the thinking mode must be passed back to the API"。 Twilight 侧只为带 tool_calls 的 assistant 消息补 reasoning_content, 覆盖不到这种被剥空的尾部消息;ask_user 续跑不受影响,因为 ask_user 的 调用与结果在裁剪时被显式保留。 修复:applyToolExchangePolicy 在没有当前用户消息槽位、且历史最后一条是 tool result 或仍带 tool call 的 assistant 消息时,将最后一条 user 之后 的尾部视为进行中的轮次,不做裁剪。以 assistant 文本结束的历史轮次仍按 原逻辑裁剪;正常轮次始终带有当前用户消息槽位,行为不变。 同时在 ask_user / 工具审批续跑循环中记录 native error 事件的原始文本, publicAgentStreamEvent 之前只留下 agent.response_interrupted 这一稳定码。 验证:dev 环境 deepseek-v4-flash,长历史 session 内触发 exec 审批并批准。 修复前续跑请求尾部为纯文本 assistant,返回 400;修复后请求尾部包含带 reasoning_content 与 tool_calls 的 assistant 消息和 tool result, run 正常 completed,模型基于工具结果作答。 --- .../contextview/selector_tool_exchange.go | 52 ++++++++++- .../selector_tool_exchange_test.go | 88 +++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/internal/contextview/selector_tool_exchange.go b/internal/contextview/selector_tool_exchange.go index 458f544e1b..1cf07ceabd 100644 --- a/internal/contextview/selector_tool_exchange.go +++ b/internal/contextview/selector_tool_exchange.go @@ -16,9 +16,10 @@ func applyToolExchangePolicy(frags []contextfrag.ContextFrag, policy *contextfra return frags, nil, nil } kept = make([]contextfrag.ContextFrag, 0, len(frags)) - for _, frag := range frags { + openTurnStart := openTurnTailStart(frags) + for i, frag := range frags { msg := contextfrag.FragMessage(frag) - if msg == nil || frag.Slot != contextfrag.SlotHistory { + if msg == nil || frag.Slot != contextfrag.SlotHistory || (openTurnStart >= 0 && i > openTurnStart) { kept = append(kept, frag) continue } @@ -54,6 +55,53 @@ func applyToolExchangePolicy(frags []contextfrag.ContextFrag, policy *contextfra return kept, dropped, edits } +// openTurnTailStart reports the slice index of the last history user message +// when the history ends in a turn that is still being answered, or -1. +// +// A continuation resumed after a deferred tool call (tool approval, ask_user) +// carries no current user message: the request replays the persisted history +// and ends with the parked step's tool call and the result it produced. Those +// messages are the live turn, not tool noise from an earlier exchange. Stripping +// them leaves the model a trailing assistant text with no tool call, no result +// and no reasoning, so it cannot see what the tool returned and re-issues the +// call; DeepSeek thinking mode additionally rejects the request because that +// trailing assistant message carries no reasoning_content. The tail is exempt +// only when no current user frag exists and the last history message is a tool +// result or an assistant message that still holds tool calls; a turn that +// concluded with plain assistant text is finished history and strips as usual. +func openTurnTailStart(frags []contextfrag.ContextFrag) int { + lastUser := -1 + lastHistory := -1 + for i, frag := range frags { + if frag.Slot == contextfrag.SlotCurrentUser || frag.Kind == contextfrag.KindCurrentUserMessage { + return -1 + } + msg := contextfrag.FragMessage(frag) + if msg == nil || frag.Slot != contextfrag.SlotHistory { + continue + } + lastHistory = i + if msg.Role == sdk.MessageRoleUser { + lastUser = i + } + } + if lastUser < 0 || lastHistory <= lastUser { + return -1 + } + last := contextfrag.FragMessage(frags[lastHistory]) + if last.Role == sdk.MessageRoleTool { + return lastUser + } + if last.Role == sdk.MessageRoleAssistant { + for _, part := range last.Content { + if _, ok := part.(sdk.ToolCallPart); ok { + return lastUser + } + } + } + return -1 +} + func countMessageFrags(frags []contextfrag.ContextFrag) int { count := 0 for _, frag := range frags { diff --git a/internal/contextview/selector_tool_exchange_test.go b/internal/contextview/selector_tool_exchange_test.go index 571d99bfe8..5a877fdd5a 100644 --- a/internal/contextview/selector_tool_exchange_test.go +++ b/internal/contextview/selector_tool_exchange_test.go @@ -62,3 +62,91 @@ func TestToolExchangePolicyThresholdAndNilPreserveEverything(t *testing.T) { } } } + +// continuationTailFixture models a tool-approval continuation: the history +// replays an earlier finished turn and then the turn still being answered, +// whose parked step (reasoning + text + exec call) already has its result. No +// current user message exists because the continuation carries no new query. +func continuationTailFixture() []contextfrag.ContextFrag { + parked := sdk.Message{Role: sdk.MessageRoleAssistant, Content: []sdk.MessagePart{ + sdk.ReasoningPart{Format: sdk.ReasoningFormatOpenAIChat, Text: "need to touch the file"}, + sdk.TextPart{Text: "creating it now"}, + sdk.ToolCallPart{ToolCallID: "exec-1", ToolName: "exec", Input: map[string]any{"command": "touch /tmp/x"}}, + }} + return []contextfrag.ContextFrag{ + historyMessageFrag("h0", sdk.UserMessage("earlier question")), + historyMessageFrag("h1", assistantToolCallMessage("call-1", "web_search", "let me look")), + historyMessageFrag("h2", toolResultMessage("call-1", "web_search", "bulky result")), + historyMessageFrag("h3", sdk.AssistantMessage("earlier answer")), + historyMessageFrag("h4", sdk.UserMessage("create a file in /tmp")), + historyMessageFrag("h5", parked), + historyMessageFrag("h6", toolResultMessage("exec-1", "exec", "created")), + } +} + +func selectedByID(result SelectionResult) map[string]contextfrag.ContextFrag { + out := make(map[string]contextfrag.ContextFrag, len(result.Selected)) + for _, frag := range result.Selected { + out[frag.ID] = frag + } + return out +} + +func TestToolExchangePolicyKeepsUnfinishedTurnTailOnContinuation(t *testing.T) { + t.Parallel() + selector := &FragmentSelector{} + result := selector.Select(continuationTailFixture(), selector.ProfileFor(contextfrag.IntentRunConfigPreProvider), BudgetEnvelope{ToolExchange: &contextfrag.ToolExchangePolicy{}}) + selected := selectedByID(result) + if _, ok := selected["h2"]; ok { + t.Fatalf("earlier tool result survived: %#v", selected["h2"]) + } + for _, part := range contextfrag.FragMessage(selected["h1"]).Content { + if _, ok := part.(sdk.ToolCallPart); ok { + t.Fatalf("earlier tool call survived: %#v", part) + } + } + parked, ok := selected["h5"] + if !ok { + t.Fatalf("parked step dropped: %#v", result.Summary.DropReasons) + } + var hasCall, hasReasoning bool + for _, part := range contextfrag.FragMessage(parked).Content { + switch part.(type) { + case sdk.ToolCallPart: + hasCall = true + case sdk.ReasoningPart: + hasReasoning = true + } + } + if !hasCall || !hasReasoning { + t.Fatalf("parked step lost its tool call or reasoning: %#v", contextfrag.FragMessage(parked).Content) + } + if _, ok := selected["h6"]; !ok { + t.Fatalf("parked step result dropped: %#v", result.Summary.DropReasons) + } +} + +func TestToolExchangePolicyStripsPreviousTurnWhenCurrentUserPresent(t *testing.T) { + t.Parallel() + frags := continuationTailFixture() + current := sdk.UserMessage("and now a new question") + frags = append(frags, contextfrag.MessageFrag(contextfrag.MessageFragInput{ + ID: "c0", Message: current, Kind: contextfrag.KindCurrentUserMessage, Slot: contextfrag.SlotCurrentUser, + Scope: contextfrag.Scope{BotID: "bot-1"}, Source: "run_config_fields", Collector: "materialized_current_user", + })) + selector := &FragmentSelector{} + result := selector.Select(frags, selector.ProfileFor(contextfrag.IntentRunConfigPreProvider), BudgetEnvelope{ToolExchange: &contextfrag.ToolExchangePolicy{}}) + selected := selectedByID(result) + if _, ok := selected["h6"]; ok { + t.Fatalf("previous turn tool result survived with a current user message present") + } + for _, part := range contextfrag.FragMessage(selected["h5"]).Content { + switch part.(type) { + case sdk.ToolCallPart, sdk.ReasoningPart: + t.Fatalf("previous turn kept tool exchange part: %#v", part) + } + } + if _, ok := selected["c0"]; !ok { + t.Fatalf("current user message dropped") + } +}