Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
52 changes: 50 additions & 2 deletions internal/contextview/selector_tool_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions internal/contextview/selector_tool_exchange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading