Shape provider messages before they are sent
ci / vet-and-test (push) Successful in 49s
ci / docker-checkout-hostgateway (push) Successful in 1s

Merge pull request 'Shape provider messages before they are sent' (#16) from feature/sillytavern-review into main
This commit was merged in pull request #16.
This commit is contained in:
2026-10-04 19:44:56 +00:00
4 changed files with 103 additions and 0 deletions
+3
View File
@@ -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),
+60
View File
@@ -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
}
+38
View File
@@ -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)
}
}
+2
View File
@@ -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