From 693e2dcb3f478c80255adca85538ddbcd9558658 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 13 Mar 2026 15:59:43 +0000 Subject: [PATCH] fix(providers): serialize tool calls and tool results in conversation history ToMessage() was dropping tool_calls from assistant messages and tool_call_id from tool result messages, sending only role+content for all non-system messages. This caused multi-turn tool calling to fail because the LLM could not correlate tool results back to calls in the conversation history. --- .../OpenAiCompatibleChatClientTests.cs | 48 +++++++++++++++++++ .../OpenAiCompatibleChatClient.cs | 42 +++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/Netclaw.Daemon.Tests/Configuration/OpenAiCompatibleChatClientTests.cs b/src/Netclaw.Daemon.Tests/Configuration/OpenAiCompatibleChatClientTests.cs index b85773c3b..0702e68cd 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/OpenAiCompatibleChatClientTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/OpenAiCompatibleChatClientTests.cs @@ -154,6 +154,54 @@ public async Task BuffersFragmentedToolCallArguments_UntilFinishReason() Assert.Equal("what is TextForge", toolCall.Arguments!["Query"]?.ToString()); } + [Fact] + public async Task SerializesAssistantToolCalls_AndToolResults_InConversationHistory() + { + string? body = null; + using var handler = new RecordingHandler(req => + { + body = req.Content is null ? null : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"id\":\"1\",\"model\":\"test\",\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"The answer is 4.\"}}]}", + Encoding.UTF8, "application/json") + }; + }); + using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8000") }; + var endpoint = OpenAiCompatibleEndpoint.FromBaseUrl("http://localhost:8000"); + var client = new OpenAiCompatibleChatClient(httpClient, endpoint, "test-model"); + + // Simulate a conversation with tool call history + var assistantWithToolCall = new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("call_42", "calculator", new Dictionary { ["expression"] = "2+2" }) + ]); + var toolResult = new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("call_42", "4") + ]); + + await client.GetResponseAsync( + [ + new ChatMessage(ChatRole.User, "What is 2+2?"), + assistantWithToolCall, + toolResult, + new ChatMessage(ChatRole.User, "Thanks, and what about 3+3?") + ]); + + Assert.NotNull(body); + + // Assistant message should include tool_calls array with id, function name, arguments + Assert.Contains("\"tool_calls\":", body, StringComparison.Ordinal); + Assert.Contains("\"id\":\"call_42\"", body, StringComparison.Ordinal); + Assert.Contains("\"name\":\"calculator\"", body, StringComparison.Ordinal); + + // Tool result message should include tool_call_id + Assert.Contains("\"tool_call_id\":\"call_42\"", body, StringComparison.Ordinal); + Assert.Contains("\"role\":\"tool\"", body, StringComparison.Ordinal); + } + private sealed class RecordingHandler : HttpMessageHandler { private readonly Func _handler; diff --git a/src/Netclaw.OpenAICompatible/OpenAiCompatibleChatClient.cs b/src/Netclaw.OpenAICompatible/OpenAiCompatibleChatClient.cs index efc9a7370..1d1835318 100644 --- a/src/Netclaw.OpenAICompatible/OpenAiCompatibleChatClient.cs +++ b/src/Netclaw.OpenAICompatible/OpenAiCompatibleChatClient.cs @@ -167,11 +167,49 @@ private async Task EnsureSuccessAsync(HttpResponseMessage response, Dictionary().ToList(); + var toolResult = message.Contents.OfType().FirstOrDefault(); + + // Tool result message — needs tool_call_id + if (message.Role == ChatRole.Tool && toolResult is not null) + { + return new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = toolResult.CallId, + ["content"] = toolResult.Result?.ToString() ?? string.Empty + }; + } + + // Assistant message with tool calls — needs tool_calls array + if (message.Role == ChatRole.Assistant && toolCalls.Count > 0) + { + var text = message.Contents.OfType().FirstOrDefault()?.Text; + var calls = toolCalls.Select(tc => new Dictionary + { + ["id"] = tc.CallId, + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = tc.Name, + ["arguments"] = tc.Arguments is not null + ? JsonSerializer.Serialize(tc.Arguments, JsonOptions) + : "{}" + } + }).ToArray(); + + return new Dictionary + { + ["role"] = "assistant", + ["content"] = text, + ["tool_calls"] = calls + }; + } + return new Dictionary { ["role"] = ToRole(message.Role), - ["content"] = text + ["content"] = message.Text }; }