diff --git a/runtime/llm/client.go b/runtime/llm/client.go index acda4df..ff85756 100644 --- a/runtime/llm/client.go +++ b/runtime/llm/client.go @@ -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), diff --git a/runtime/llm/prepare.go b/runtime/llm/prepare.go new file mode 100644 index 0000000..410b1ce --- /dev/null +++ b/runtime/llm/prepare.go @@ -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 +} diff --git a/runtime/llm/prepare_test.go b/runtime/llm/prepare_test.go new file mode 100644 index 0000000..4d4e7ec --- /dev/null +++ b/runtime/llm/prepare_test.go @@ -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) + } +} diff --git a/runtime/llm/types.go b/runtime/llm/types.go index b886af2..28f61e6 100644 --- a/runtime/llm/types.go +++ b/runtime/llm/types.go @@ -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