Shape provider messages before they are sent
Why: Some endpoints reject consecutive system messages or a run of the same role. The product already emits several system messages in one request. Evidence: go test is covered by PrepareMessages tests for merge, strict folding, and tool pairs. Empty MessageShape leaves the transcript unchanged. Impact: Callers opt in with StreamOptions.MessageShape. Tool calls and tool results are not merged. Refs: docs/records/2026-10-04-sillytavern-review.md
This commit is contained in:
@@ -42,6 +42,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
||||
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
|
||||
return
|
||||
}
|
||||
if opts.MessageShape != "" {
|
||||
messages = PrepareMessages(messages, opts.MessageShape)
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"messages": toProviderMessages(messages),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package llm
|
||||
|
||||
// PrepareMessages 按 endpoint 整形消息。
|
||||
// merge 合并相邻、且不携带工具调用的同角色消息。
|
||||
// strict 在此之后把第一条之后的 system 收成 user,再合并,满足严格交替。
|
||||
// 工具消息和带 tool_calls 的助手消息保持原样,避免拆开调用与结果。
|
||||
func PrepareMessages(msgs []Message, shape string) []Message {
|
||||
switch shape {
|
||||
case "", "merge", "strict":
|
||||
default:
|
||||
return msgs
|
||||
}
|
||||
out := mergeSameRole(msgs)
|
||||
if shape == "strict" {
|
||||
out = foldExtraSystems(out)
|
||||
out = mergeSameRole(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeable(m Message) bool {
|
||||
return m.Role != RoleTool && len(m.ToolCalls) == 0 && m.ToolCallID == ""
|
||||
}
|
||||
|
||||
func mergeSameRole(msgs []Message) []Message {
|
||||
if len(msgs) == 0 {
|
||||
return msgs
|
||||
}
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if n := len(out); n > 0 && out[n-1].Role == m.Role && mergeable(out[n-1]) && mergeable(m) {
|
||||
if out[n-1].Content != "" && m.Content != "" {
|
||||
out[n-1].Content += "\n\n" + m.Content
|
||||
} else {
|
||||
out[n-1].Content += m.Content
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func foldExtraSystems(msgs []Message) []Message {
|
||||
seenSystem := false
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == RoleSystem {
|
||||
if seenSystem {
|
||||
m.Role = RoleUser
|
||||
if m.Content != "" {
|
||||
m.Content = "[system]\n" + m.Content
|
||||
}
|
||||
}
|
||||
seenSystem = true
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package llm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPrepareMergesAdjacentSystems(t *testing.T) {
|
||||
out := PrepareMessages([]Message{
|
||||
{Role: RoleSystem, Content: "a"},
|
||||
{Role: RoleSystem, Content: "b"},
|
||||
{Role: RoleUser, Content: "hi"},
|
||||
}, "merge")
|
||||
if len(out) != 2 || out[0].Content != "a\n\nb" {
|
||||
t.Fatalf("merged = %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareStrictFoldsLaterSystem(t *testing.T) {
|
||||
out := PrepareMessages([]Message{
|
||||
{Role: RoleSystem, Content: "base"},
|
||||
{Role: RoleUser, Content: "hi"},
|
||||
{Role: RoleSystem, Content: "note"},
|
||||
}, "strict")
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len = %d %#v", len(out), out)
|
||||
}
|
||||
if out[1].Role != RoleUser || out[1].Content != "hi\n\n[system]\nnote" {
|
||||
t.Fatalf("folded = %#v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareKeepsToolPairs(t *testing.T) {
|
||||
out := PrepareMessages([]Message{
|
||||
{Role: RoleAssistant, Content: "call", ToolCalls: []ToolCall{{ID: "1", Name: "t"}}},
|
||||
{Role: RoleTool, ToolCallID: "1", Content: "ok"},
|
||||
}, "merge")
|
||||
if len(out) != 2 || out[1].Role != RoleTool {
|
||||
t.Fatalf("tools = %#v", out)
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,8 @@ type StreamOptions struct {
|
||||
// ThinkingLevel is off, minimal, low, medium, high, xhigh, or max.
|
||||
// Empty leaves the provider default unchanged.
|
||||
ThinkingLevel string
|
||||
// MessageShape is merge, strict, or empty. Empty leaves the transcript unchanged.
|
||||
MessageShape string
|
||||
}
|
||||
|
||||
// StreamFunc is the provider boundary. It must return a channel that is closed
|
||||
|
||||
Reference in New Issue
Block a user