using System.Net.Http; using System.Runtime.CompilerServices; using System.IO; using System.Text; using System.Text.Json; using AxCopilot.Models; using AxCopilot.Services.Agent; namespace AxCopilot.Services; /// LLM 응답에서 파싱된 컨텐츠 블록. public class ContentBlock { public string Type { get; init; } = "text"; // "text" | "tool_use" public string Text { get; init; } = ""; // text 타입일 때 public string ToolName { get; init; } = ""; // tool_use 타입일 때 public string ToolId { get; init; } = ""; // tool_use ID public JsonElement? ToolInput { get; init; } // tool_use 파라미터 public string? ResolvedToolName { get; set; } public Task? PrefetchedExecutionTask { get; set; } } public sealed record ToolPrefetchResult( Agent.ToolResult Result, long ElapsedMilliseconds, string? ResolvedToolName = null); public enum ToolStreamEventKind { TextDelta, ToolCallReady, RetryReset, Completed } public sealed record ToolStreamEvent( ToolStreamEventKind Kind, string Text = "", ContentBlock? ToolCall = null); /// /// LlmService의 Function Calling (tool_use) 확장. /// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다. /// 기존 SendAsync/StreamAsync는 변경하지 않고, 에이전트 전용 메서드를 추가합니다. /// public partial class LlmService { /// 도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다. /// /// true이면 tool_choice: "required"를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다. /// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에 사용하세요. /// Claude/Gemini는 지원하지 않으므로 vLLM/Ollama/OpenAI 계열에만 적용됩니다. /// public async Task> SendWithToolsAsync( List messages, IReadOnlyCollection tools, CancellationToken ct = default, bool forceToolCall = false, Func>? prefetchToolCallAsync = null) { var activeService = ResolveService(); EnsureOperationModeAllowsLlmService(activeService); return NormalizeServiceName(activeService) switch { "sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct).ConfigureAwait(false), "gemini" => await SendGeminiWithToolsAsync(messages, tools, ct).ConfigureAwait(false), "ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync).ConfigureAwait(false), _ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.") }; } public async IAsyncEnumerable StreamWithToolsAsync( List messages, IReadOnlyCollection tools, bool forceToolCall = false, Func>? prefetchToolCallAsync = null, [EnumeratorCancellation] CancellationToken ct = default) { var activeService = ResolveService(); EnsureOperationModeAllowsLlmService(activeService); switch (NormalizeServiceName(activeService)) { case "ollama": case "vllm": await foreach (var evt in StreamOpenAiToolEventsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync).WithCancellation(ct)) yield return evt; yield break; case "sigmoid": foreach (var block in await SendSigmoidWithToolsAsync(messages, tools, ct)) { if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text)) yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, block.Text); else if (block.Type == "tool_use") yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: block); } yield return new ToolStreamEvent(ToolStreamEventKind.Completed); yield break; case "gemini": foreach (var block in await SendGeminiWithToolsAsync(messages, tools, ct)) { if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text)) yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, block.Text); else if (block.Type == "tool_use") yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: block); } yield return new ToolStreamEvent(ToolStreamEventKind.Completed); yield break; default: throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling 스트리밍을 지원하지 않습니다."); } } /// 도구 실행 결과를 LLM에 피드백하기 위한 메시지를 생성합니다. public static ChatMessage CreateToolResultMessage(string toolId, string toolName, string result) { // Claude: role=user, content=[{type:"tool_result", tool_use_id, content}] // 내부적으로는 JSON으로 인코딩하여 Content에 저장 var payload = JsonSerializer.Serialize(new { type = "tool_result", tool_use_id = toolId, tool_name = toolName, content = result }); return new ChatMessage { Role = "user", Content = payload, Timestamp = DateTime.Now, }; } // ─── Claude Function Calling ────────────────────────────────────── private async Task> SendSigmoidWithToolsAsync( List messages, IReadOnlyCollection tools, CancellationToken ct) { var apiKey = ResolveApiKeyForService("sigmoid"); if (string.IsNullOrEmpty(apiKey)) throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다."); var body = BuildSigmoidToolBody(messages, tools); var json = JsonSerializer.Serialize(body); using var req = new HttpRequestMessage(HttpMethod.Post, $"https://{SigmoidApiHost}/v1/messages"); req.Content = new StringContent(json, Encoding.UTF8, "application/json"); req.Headers.Add("x-api-key", apiKey); req.Headers.Add(SigmoidApiVersionHeader, SigmoidApiVersion); using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) { var errBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); throw new HttpRequestException(ClassifyHttpError(resp, errBody)); } var respJson = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(respJson); var root = doc.RootElement; // 토큰 사용량 if (root.SafeTryGetProperty("usage", out var usage)) TryParseSigmoidUsageFromElement(usage); // 컨텐츠 블록 파싱 var blocks = new List(); if (root.SafeTryGetProperty("content", out var content)) { foreach (var block in content.EnumerateArray()) { var type = block.SafeTryGetProperty("type", out var tp) ? tp.SafeGetString() : ""; if (type == "text") { blocks.Add(new ContentBlock { Type = "text", Text = block.SafeTryGetProperty("text", out var txt) ? txt.SafeGetString() ?? "" : "" }); } else if (type == "tool_use") { blocks.Add(new ContentBlock { Type = "tool_use", ToolName = block.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "", ToolId = block.SafeTryGetProperty("id", out var bid) ? bid.SafeGetString() ?? "" : "", ToolInput = block.SafeTryGetProperty("input", out var inp) ? inp.Clone() : null }); } } } return blocks; } private object BuildSigmoidToolBody(List messages, IReadOnlyCollection tools) { var llm = _settings.Settings.Llm; var msgs = new List(); foreach (var m in messages) { if (m.Role == "system") continue; // tool_result 메시지인지 확인 if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) { try { using var doc = JsonDocument.Parse(m.Content); var root = doc.RootElement; msgs.Add(new { role = "user", content = new object[] { new { type = "tool_result", tool_use_id = root.SafeTryGetProperty("tool_use_id", out var tuid) ? tuid.SafeGetString() : "", content = root.SafeTryGetProperty("content", out var tcont) ? tcont.SafeGetString() : "" } } }); continue; } catch { /* 파싱 실패시 일반 메시지로 처리 */ } } // assistant 메시지에 tool_use 블록이 포함된 경우 (에이전트 루프) if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) { try { using var doc = JsonDocument.Parse(m.Content); if (!doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception(); var contentList = new List(); foreach (var b in blocksArr.EnumerateArray()) { var bType = b.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString() : ""; if (bType == "text") contentList.Add(new { type = "text", text = b.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "" }); else if (bType == "tool_use") contentList.Add(new { type = "tool_use", id = b.SafeTryGetProperty("id", out var bid) ? bid.SafeGetString() ?? "" : "", name = b.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "", input = b.SafeTryGetProperty("input", out var inp) ? (object)inp.Clone() : new { } }); } msgs.Add(new { role = "assistant", content = contentList }); continue; } catch { /* 파싱 실패시 일반 메시지로 처리 */ } } // Claude Vision: 이미지가 있으면 content를 배열로 변환 if (m.Images?.Count > 0 && m.Role == "user") { var contentParts = new List(); foreach (var img in m.Images) contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } }); contentParts.Add(new { type = "text", text = m.Content }); msgs.Add(new { role = m.Role, content = contentParts }); } else { msgs.Add(new { role = m.Role, content = m.Content }); } } // 도구 정의 var toolDefs = tools.Select(t => new { name = t.Name, description = t.Description, input_schema = new { type = "object", properties = t.Parameters.Properties.ToDictionary( kv => kv.Key, kv => BuildPropertySchema(kv.Value, false)), required = t.Parameters.Required } }).ToArray(); // 시스템 프롬프트 var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt; var activeModel = ResolveModel(); if (!string.IsNullOrEmpty(systemPrompt)) { return new { model = activeModel, max_tokens = ResolveOpenAiCompatibleMaxTokens(), temperature = llm.Temperature, system = systemPrompt, messages = msgs, tools = toolDefs, stream = false }; } return new { model = activeModel, max_tokens = ResolveOpenAiCompatibleMaxTokens(), temperature = llm.Temperature, messages = msgs, tools = toolDefs, stream = false }; } // ─── Gemini Function Calling ─────────────────────────────────────── private async Task> SendGeminiWithToolsAsync( List messages, IReadOnlyCollection tools, CancellationToken ct) { var llm = _settings.Settings.Llm; var apiKey = ResolveApiKeyForService("gemini"); if (string.IsNullOrEmpty(apiKey)) throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다."); var activeModel = ResolveModel(); var body = BuildGeminiToolBody(messages, tools); var url = $"https://generativelanguage.googleapis.com/v1beta/models/{activeModel}:generateContent?key={apiKey}"; var json = JsonSerializer.Serialize(body); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var resp = await _http.PostAsync(url, content, ct); if (!resp.IsSuccessStatusCode) { var errBody = await resp.Content.ReadAsStringAsync(ct); throw new HttpRequestException($"Gemini API 오류 ({resp.StatusCode}): {errBody}"); } var respJson = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(respJson); var root = doc.RootElement; TryParseGeminiUsage(root); var blocks = new List(); if (root.SafeTryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0) { var firstCandidate = candidates[0]; if (firstCandidate.SafeTryGetProperty("content", out var contentObj) && contentObj.SafeTryGetProperty("parts", out var parts)) { foreach (var part in parts.EnumerateArray()) { if (part.SafeTryGetProperty("text", out var text)) { blocks.Add(new ContentBlock { Type = "text", Text = text.SafeGetString() ?? "" }); } else if (part.SafeTryGetProperty("functionCall", out var fc)) { blocks.Add(new ContentBlock { Type = "tool_use", ToolName = fc.SafeTryGetProperty("name", out var fcName) ? fcName.SafeGetString() ?? "" : "", ToolId = Guid.NewGuid().ToString("N")[..12], ToolInput = fc.SafeTryGetProperty("args", out var a) ? a.Clone() : null }); } } } } return blocks; } private object BuildGeminiToolBody(List messages, IReadOnlyCollection tools) { var contents = new List(); foreach (var m in messages) { if (m.Role == "system") continue; var role = m.Role == "assistant" ? "model" : "user"; // tool_result 메시지 처리 if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) { try { using var doc = JsonDocument.Parse(m.Content); var root = doc.RootElement; var toolName = root.SafeTryGetProperty("tool_name", out var tn) ? tn.SafeGetString() ?? "" : ""; var toolContent = root.SafeTryGetProperty("content", out var tc) ? tc.SafeGetString() ?? "" : ""; contents.Add(new { role = "function", parts = new object[] { new { functionResponse = new { name = toolName, response = new { result = toolContent } } } } }); continue; } catch { } } // assistant 메시지에 tool_use 블록이 포함된 경우 (에이전트 루프) if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) { try { using var doc = JsonDocument.Parse(m.Content); if (doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocksArr)) { var parts = new List(); foreach (var b in blocksArr.EnumerateArray()) { var bType = b.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString() : ""; if (bType == "text") parts.Add(new { text = b.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "" }); else if (bType == "tool_use") parts.Add(new { functionCall = new { name = b.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "", args = b.SafeTryGetProperty("input", out var inp) ? (object)inp.Clone() : new { } } }); } contents.Add(new { role = "model", parts }); continue; } } catch { } } // Gemini Vision: 이미지가 있으면 parts에 inlineData 추가 if (m.Images?.Count > 0 && m.Role == "user") { var imgParts = new List { new { text = m.Content } }; foreach (var img in m.Images) imgParts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } }); contents.Add(new { role, parts = imgParts }); } else { contents.Add(new { role, parts = new[] { new { text = m.Content } } }); } } // 도구 정의 (Gemini function_declarations 형식) var funcDecls = tools.Select(t => new { name = t.Name, description = t.Description, parameters = new { type = "OBJECT", properties = t.Parameters.Properties.ToDictionary( kv => kv.Key, kv => BuildPropertySchema(kv.Value, true)), required = t.Parameters.Required } }).ToArray(); var systemInstruction = messages.FirstOrDefault(m => m.Role == "system"); var body = new Dictionary { ["contents"] = contents, ["tools"] = new[] { new { function_declarations = funcDecls } }, ["generationConfig"] = new { temperature = ResolveTemperature(), maxOutputTokens = _settings.Settings.Llm.MaxContextTokens, } }; if (systemInstruction != null) { body["systemInstruction"] = new { parts = new[] { new { text = systemInstruction.Content } } }; } return body; } // ─── OpenAI Compatible (Ollama / vLLM) Function Calling ────────── private async Task> SendOpenAiWithToolsAsync( List messages, IReadOnlyCollection tools, CancellationToken ct, bool forceToolCall = false, Func>? prefetchToolCallAsync = null) { var activeService = ResolveService(); // 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo) var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo(); var endpoint = string.IsNullOrEmpty(resolvedEp) ? ResolveEndpointForService(activeService) : resolvedEp; var registered = GetActiveRegisteredModel(); var isIbmDeployment = UsesIbmDeploymentChatApi(activeService, registered, endpoint); var body = isIbmDeployment ? BuildIbmToolBody(messages, tools, forceToolCall) : BuildOpenAiToolBody(messages, tools, forceToolCall); string url; if (isIbmDeployment) url = BuildIbmDeploymentChatUrl(endpoint, stream: true); else if (activeService.ToLowerInvariant() == "ollama") url = endpoint.TrimEnd('/') + "/api/chat"; else url = endpoint.TrimEnd('/') + "/v1/chat/completions"; var json = JsonSerializer.Serialize(body); // Raw 요청 로깅 (상세 로그 활성 시) WorkflowLogService.LogLlmRawRequestFromContext(url, json); using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; // CP4D 또는 Bearer 인증 적용 await ApplyAuthHeaderAsync(req, ct); using var resp = await SendWithTlsAsync(req, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead); if (!resp.IsSuccessStatusCode) { var errBody = await resp.Content.ReadAsStringAsync(ct); var detail = ExtractErrorDetail(errBody); LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}"); if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400) { LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."); var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false); var fallbackJson = JsonSerializer.Serialize(fallbackBody); WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson); using var retryReq = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json") }; await ApplyAuthHeaderAsync(retryReq, ct); using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead); if (retryResp.IsSuccessStatusCode) return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, true, prefetchToolCallAsync, ct); } // 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도 if ((int)resp.StatusCode == 400) throw new ToolCallNotSupportedException( $"{activeService} API 오류 ({resp.StatusCode}): {detail}"); throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}"); } return await ReadOpenAiToolBlocksFromStreamAsync(resp, isIbmDeployment, prefetchToolCallAsync, ct); } /// /// Qwen/vLLM 등이 tool_calls 대신 텍스트로 도구 호출을 출력하는 경우를 파싱합니다. /// 지원 패턴: /// 1. <tool_call>{"name":"...", "arguments":{...}}</tool_call> /// 2. Qwen3 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call> /// 3. 여러 건의 연속 tool_call 태그 /// // ── 텍스트 폴백 파싱용 정규식 (static 캐싱 — 매 호출 재생성 방지) ── private static readonly System.Text.RegularExpressions.Regex ToolCallTagRegex = new( @"<\s*tool_call\s*>\s*([\s\S]*?)\s*<\s*/\s*tool_call\s*>", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); private static readonly System.Text.RegularExpressions.Regex ToolCallFunctionRegex = new( @"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); private static readonly System.Text.RegularExpressions.Regex ToolCallJsonRegex = new( @"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}", System.Text.RegularExpressions.RegexOptions.Compiled); internal static List TryExtractToolCallsFromText(string text) { var results = new List(); if (string.IsNullOrWhiteSpace(text)) return results; // 패턴 1: ... 태그 (Qwen 계열 기본 출력) foreach (System.Text.RegularExpressions.Match m in ToolCallTagRegex.Matches(text)) { var block = TryParseToolCallJson(m.Groups[1].Value); if (block != null) results.Add(block); } // 패턴 2: ✿FUNCTION✿ (일부 Qwen 변형) if (results.Count == 0) { foreach (System.Text.RegularExpressions.Match m in ToolCallFunctionRegex.Matches(text)) { var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value); if (block != null) results.Add(block); } } // 패턴 3: JSON 객체가 직접 출력된 경우 ({"name":"tool_name","arguments":{...}}) if (results.Count == 0) { foreach (System.Text.RegularExpressions.Match m in ToolCallJsonRegex.Matches(text)) { var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value); if (block != null) results.Add(block); } } return results; } /// {"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환. private static ContentBlock? TryParseToolCallJson(string json) { try { json = json.Trim(); // 태그 내용에서 JSON 객체 부분만 추출 (앞뒤 비-JSON 텍스트 제거) var braceStart = json.IndexOf('{'); var braceEnd = json.LastIndexOf('}'); if (braceStart >= 0 && braceEnd > braceStart) json = json[braceStart..(braceEnd + 1)]; using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var name = root.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : ""; if (string.IsNullOrEmpty(name)) return null; JsonElement? args = null; if (root.SafeTryGetProperty("arguments", out var a)) args = a.Clone(); else if (root.SafeTryGetProperty("parameters", out var p)) args = p.Clone(); return new ContentBlock { Type = "tool_use", ToolName = name, ToolId = $"text_fc_{Guid.NewGuid():N}"[..16], ToolInput = args, }; } catch (Exception ex) { Services.LogService.Debug($"[ToolUse] tool_call JSON 파싱 실패: {ex.Message} | 원본: {(json.Length > 200 ? json[..200] + "…" : json)}"); return null; } } /// 이름과 arguments JSON이 별도로 주어진 경우. private static ContentBlock? TryParseToolCallJsonWithName(string name, string argsJson) { if (string.IsNullOrWhiteSpace(name)) return null; try { using var doc = JsonDocument.Parse(argsJson); return new ContentBlock { Type = "tool_use", ToolName = name.Trim(), ToolId = $"text_fc_{Guid.NewGuid():N}"[..16], ToolInput = doc.RootElement.Clone(), }; } catch (Exception ex) { Services.LogService.Debug($"[ToolUse] tool_call JSON 파싱 실패 (name={name}): {ex.Message}"); return null; } } private object BuildOpenAiToolBody(List messages, IReadOnlyCollection tools, bool forceToolCall = false) { var llm = _settings.Settings.Llm; var msgs = new List(); var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages); for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++) { var m = messages[messageIndex]; var keepStructuredHistory = messageIndex >= structuredHistoryStart; // tool_result 메시지 → OpenAI tool 응답 형식 if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) { try { using var doc = JsonDocument.Parse(m.Content); var root = doc.RootElement; if (!keepStructuredHistory) { msgs.Add(new { role = "user", content = BuildOpenAiToolResultTranscript(root), }); continue; } msgs.Add(new { role = "tool", tool_call_id = root.GetProperty("tool_use_id").SafeGetString(), content = root.GetProperty("content").SafeGetString(), }); continue; } catch { } } // assistant 메시지에 tool_use 블록이 포함된 경우 if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) { try { using var doc = JsonDocument.Parse(m.Content); var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks"); if (!keepStructuredHistory) { msgs.Add(new { role = "assistant", content = BuildOpenAiAssistantTranscript(blocksArr), }); continue; } var textContent = ""; var toolCallsList = new List(); foreach (var b in blocksArr.EnumerateArray()) { var bType = b.GetProperty("type").SafeGetString(); if (bType == "text") textContent = b.GetProperty("text").SafeGetString() ?? ""; else if (bType == "tool_use") { var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}"; toolCallsList.Add(new { id = b.GetProperty("id").SafeGetString() ?? "", type = "function", function = new { name = b.GetProperty("name").SafeGetString() ?? "", arguments = argsJson, } }); } } msgs.Add(new { role = "assistant", content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent, tool_calls = toolCallsList, }); continue; } catch { } } // ── 이미지 첨부 (Vision) ── if (m.Role == "user" && m.Images?.Count > 0) { var contentParts = new List(); foreach (var img in m.Images) contentParts.Add(new { type = "image_url", image_url = new { url = $"data:{img.MimeType};base64,{img.Base64}" } }); contentParts.Add(new { type = "text", text = m.Content }); msgs.Add(new { role = m.Role, content = contentParts }); } else { msgs.Add(new { role = m.Role, content = m.Content }); } } // ── tool_calls ↔ tool 메시지 쌍 검증 ── // 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데 // 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함. // 깨진 tool_calls 메시지를 일반 assistant 텍스트로 평탄화하여 방지. SanitizeToolCallPairs(msgs); // OpenAI 도구 정의 var toolDefs = tools.Select(t => { // parameters 객체: required가 비어있으면 생략 (일부 Ollama 버전 호환) var paramDict = new Dictionary { ["type"] = "object", ["properties"] = t.Parameters.Properties.ToDictionary( kv => kv.Key, kv => BuildPropertySchema(kv.Value, false)), }; if (t.Parameters.Required is { Count: > 0 }) paramDict["required"] = t.Parameters.Required; return new { type = "function", function = new { name = t.Name, description = t.Description, parameters = paramDict, } }; }).ToArray(); var activeService = ResolveService(); var activeModel = ResolveModel(); var executionPolicy = GetActiveExecutionPolicy(); var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase); if (isOllama) { // Ollama /api/chat 전용 바디 — stream:false로 비스트리밍 응답 // Ollama 0.5.x+ 에서 tool_choice 파라미터 지원 (미지원 버전은 무시됨) var ollamaBody = new Dictionary { ["model"] = activeModel, ["messages"] = msgs, ["tools"] = toolDefs, ["stream"] = false, ["options"] = new { temperature = ResolveToolTemperature() } }; // Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용 if (forceToolCall) ollamaBody["tool_choice"] = "required"; return ollamaBody; } var body = new Dictionary { ["model"] = activeModel, ["messages"] = msgs, ["tools"] = toolDefs, ["stream"] = true, ["temperature"] = ResolveToolTemperature(), ["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(), ["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch, }; // 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환) body["stream_options"] = new { include_usage = true }; // tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제 // 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응) if (forceToolCall) body["tool_choice"] = "required"; var effort = ResolveReasoningEffort(); if (!string.IsNullOrWhiteSpace(effort)) body["reasoning_effort"] = effort; return body; } private static int GetStructuredToolHistoryStartIndex(IReadOnlyList messages) { const int protectedRecentNonSystemMessages = 8; var nonSystemMessages = messages .Select((message, index) => new { message, index }) .Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase)) .ToList(); if (nonSystemMessages.Count <= protectedRecentNonSystemMessages) return 0; var tentativeStart = Math.Max(0, nonSystemMessages.Count - protectedRecentNonSystemMessages); var adjustedStart = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs( nonSystemMessages.Select(x => x.message).ToList(), tentativeStart, out _); return nonSystemMessages[Math.Max(0, adjustedStart)].index; } /// IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음. private object BuildIbmToolBody( List messages, IReadOnlyCollection tools, bool forceToolCall = false, bool useToolChoice = true) { var executionPolicy = GetActiveExecutionPolicy(); var strictToolOnlyDirective = forceToolCall && string.Equals(executionPolicy.Key, "tool_call_strict", StringComparison.OrdinalIgnoreCase); var msgs = new List(); // 시스템 프롬프트 + IBM/vLLM 도구 호출 가이드 주입 var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt; // tools 이름 목록을 시스템 프롬프트에 직접 삽입 → Qwen이 도구 이름을 확실히 인식 var toolNameList = string.Join(", ", tools.Select(t => t.Name)); var toolCallGuidance = "\n\n[Tool Calling Instructions]\n" + "You have access to the following tools: " + toolNameList + "\n" + "When the user's request requires action, you MUST call a tool. NEVER describe what you would do — call the tool directly.\n\n" + "To call a tool, output EXACTLY this format (one per tool):\n" + "\n" + "{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}\n" + "\n\n" + "Rules:\n" + "- You MUST call at least one tool for every user request that requires action.\n" + "- Do NOT explain what you plan to do. Do NOT say \"I will\" or \"Let me\". Just output immediately.\n" + "- If multiple tools are needed, output multiple blocks.\n" + "- After receiving tool results, use them to answer the user.\n"; var fullSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? toolCallGuidance.TrimStart() : systemPrompt + toolCallGuidance; msgs.Add(new { role = "system", content = fullSystemPrompt }); foreach (var m in messages) { if (m.Role == "system") continue; // IBM/Qwen 배포형은 과거 tool_calls/tool 메시지 이력을 엄격하게 검사하는 경우가 있어 // 이전 tool-use 대화는 평탄한 transcript로 재구성한다. if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\"")) { try { using var doc = JsonDocument.Parse(m.Content); var root = doc.RootElement; msgs.Add(new { role = "user", content = BuildIbmToolResultTranscript(root) }); continue; } catch { } } // _tool_use_blocks도 assistant + tool_calls로 다시 보내지 않고 plain assistant transcript로 평탄화 if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\"")) { try { using var doc = JsonDocument.Parse(m.Content); var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks"); msgs.Add(new { role = "assistant", content = BuildIbmAssistantTranscript(blocksArr) }); continue; } catch { } } msgs.Add(new { role = m.Role == "assistant" ? "assistant" : "user", content = m.Content }); } if (strictToolOnlyDirective) { msgs.Add(new { role = "user", content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 형식으로 도구를 호출하세요. 평문 응답은 거부됩니다.\nExample: \n{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}\n" }); } // OpenAI 호환 도구 정의 (형식 동일, watsonx에서 tools 필드 지원) var toolDefs = tools.Select(t => { var paramDict = new Dictionary { ["type"] = "object", ["properties"] = t.Parameters.Properties.ToDictionary( kv => kv.Key, kv => BuildPropertySchema(kv.Value, false)), }; if (t.Parameters.Required is { Count: > 0 }) paramDict["required"] = t.Parameters.Required; return new { type = "function", function = new { name = t.Name, description = t.Description, parameters = paramDict, } }; }).ToArray(); // IBM watsonx: parameters 래퍼 사용, model 필드 없음 // tool_choice / tool_choice_option: IBM 버전에 따라 필드명이 다를 수 있으므로 양쪽 모두 전송 // 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨 // Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨 if (forceToolCall && useToolChoice) { return new { messages = msgs, tools = toolDefs, tool_choice = "required", tool_choice_option = "required", parameters = new { temperature = ResolveToolTemperature(), max_new_tokens = ResolveOpenAiCompatibleMaxTokens() }, chat_template_kwargs = new { enable_thinking = false }, }; } return new { messages = msgs, tools = toolDefs, parameters = new { temperature = ResolveToolTemperature(), max_new_tokens = ResolveOpenAiCompatibleMaxTokens() }, chat_template_kwargs = new { enable_thinking = false }, }; } private static string BuildIbmAssistantTranscript(JsonElement blocksArr) { var textSegments = new List(); var toolSegments = new List(); foreach (var block in blocksArr.EnumerateArray()) { var blockType = block.GetProperty("type").SafeGetString(); if (string.Equals(blockType, "text", StringComparison.OrdinalIgnoreCase)) { var text = block.SafeTryGetProperty("text", out var textEl) ? textEl.SafeGetString() ?? "" : ""; if (!string.IsNullOrWhiteSpace(text)) textSegments.Add(text.Trim()); continue; } if (!string.Equals(blockType, "tool_use", StringComparison.OrdinalIgnoreCase)) continue; var name = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : ""; var args = block.SafeTryGetProperty("input", out var inputEl) ? inputEl.GetRawText() : "{}"; if (string.IsNullOrWhiteSpace(name)) continue; toolSegments.Add($"\n{{\"name\":\"{name}\",\"arguments\":{args}}}\n"); } var parts = new List(); if (textSegments.Count > 0) parts.Add(string.Join("\n\n", textSegments)); if (toolSegments.Count > 0) parts.Add(string.Join("\n", toolSegments)); return parts.Count == 0 ? "\n{\"name\":\"unknown_tool\",\"arguments\":{}}\n" : string.Join("\n\n", parts); } private static string BuildOpenAiAssistantTranscript(JsonElement blocksArr) { var textSegments = new List(); var toolNames = new List(); foreach (var block in blocksArr.EnumerateArray()) { var blockType = block.GetProperty("type").SafeGetString(); if (string.Equals(blockType, "text", StringComparison.OrdinalIgnoreCase)) { var text = block.SafeTryGetProperty("text", out var textEl) ? textEl.SafeGetString() ?? "" : ""; if (!string.IsNullOrWhiteSpace(text)) textSegments.Add(text.Trim()); continue; } if (!string.Equals(blockType, "tool_use", StringComparison.OrdinalIgnoreCase)) continue; var name = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : ""; if (!string.IsNullOrWhiteSpace(name)) toolNames.Add(name.Trim()); } var parts = new List(); if (textSegments.Count > 0) parts.Add(string.Join("\n\n", textSegments)); if (toolNames.Count > 0) parts.Add($"[이전 도구 호출: {string.Join(", ", toolNames.Distinct(StringComparer.OrdinalIgnoreCase))}]"); return parts.Count == 0 ? "[이전 도구 호출]" : string.Join("\n\n", parts); } private static string BuildOpenAiToolResultTranscript(JsonElement root) { var toolName = root.SafeTryGetProperty("tool_name", out var nameEl) ? nameEl.SafeGetString() ?? "" : ""; var content = root.SafeTryGetProperty("content", out var contentEl) ? contentEl.SafeGetString() ?? "" : ""; var header = string.IsNullOrWhiteSpace(toolName) ? "[이전 도구 결과]" : $"[이전 도구 결과: {toolName}]"; return string.IsNullOrWhiteSpace(content) ? $"{header}\n(no output)" : $"{header}\n{content}"; } private static string BuildIbmToolResultTranscript(JsonElement root) { var toolCallId = root.SafeTryGetProperty("tool_use_id", out var idEl) ? idEl.SafeGetString() ?? "" : ""; var toolName = root.SafeTryGetProperty("tool_name", out var nameEl) ? nameEl.SafeGetString() ?? "" : ""; var content = root.SafeTryGetProperty("content", out var contentEl) ? contentEl.SafeGetString() ?? "" : ""; var header = string.IsNullOrWhiteSpace(toolName) ? "[Tool Result]" : $"[Tool Result: {toolName}]"; if (!string.IsNullOrWhiteSpace(toolCallId)) header += $" (id={toolCallId})"; return string.IsNullOrWhiteSpace(content) ? $"{header}\n(no output)" : $"{header}\n{content}"; } private sealed class ToolCallAccumulator { public int Index { get; init; } public string Id { get; set; } = ""; public string Name { get; set; } = ""; public StringBuilder Arguments { get; } = new(); public bool Emitted { get; set; } } private async Task> ReadOpenAiToolBlocksFromStreamAsync( HttpResponseMessage resp, bool usesIbmDeploymentApi, Func>? prefetchToolCallAsync, CancellationToken ct) { var blocks = new List(); var textBuilder = new StringBuilder(); var rawSseBuilder = WorkflowLogService.IsRawLogEnabled ? new StringBuilder() : null; var rawSw = System.Diagnostics.Stopwatch.StartNew(); await foreach (var evt in StreamOpenAiToolEventsAsync(resp, usesIbmDeploymentApi, prefetchToolCallAsync, ct).WithCancellation(ct)) { if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text)) { textBuilder.Append(evt.Text); rawSseBuilder?.Append("[text] ").AppendLine(evt.Text); } else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null) { blocks.Add(evt.ToolCall); rawSseBuilder?.Append("[tool_call] ").Append(evt.ToolCall.ToolName) .Append(' ').AppendLine(evt.ToolCall.ToolInput?.GetRawText() ?? "{}"); } } // Raw 응답 로깅 if (rawSseBuilder != null && rawSseBuilder.Length > 0) WorkflowLogService.LogLlmRawResponseFromContext(rawSseBuilder.ToString(), rawSw.ElapsedMilliseconds); var text = textBuilder.ToString().Trim(); var result = new List(); if (!string.IsNullOrWhiteSpace(text)) result.Add(new ContentBlock { Type = "text", Text = text }); result.AddRange(blocks); if (!result.Any(b => b.Type == "tool_use")) { var textBlock = result.FirstOrDefault(b => b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text)); if (textBlock != null) { var extracted = TryExtractToolCallsFromText(textBlock.Text); if (extracted.Count > 0) { foreach (var block in extracted) { if (prefetchToolCallAsync != null) block.PrefetchedExecutionTask = prefetchToolCallAsync(block); } result.AddRange(extracted); LogService.Debug($"[ToolUse] 텍스트에서 도구 호출 {extracted.Count}건 추출 (SSE 폴백 파싱)"); } } } return result; } private async IAsyncEnumerable StreamOpenAiToolEventsAsync( List messages, IReadOnlyCollection tools, [EnumeratorCancellation] CancellationToken ct, bool forceToolCall = false, Func>? prefetchToolCallAsync = null) { var activeService = ResolveService(); var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo(); var endpoint = string.IsNullOrEmpty(resolvedEp) ? ResolveEndpointForService(activeService) : resolvedEp; var registered = GetActiveRegisteredModel(); var isIbmDeployment = UsesIbmDeploymentChatApi(activeService, registered, endpoint); var body = isIbmDeployment ? BuildIbmToolBody(messages, tools, forceToolCall) : BuildOpenAiToolBody(messages, tools, forceToolCall); string url; if (isIbmDeployment) url = BuildIbmDeploymentChatUrl(endpoint, stream: true); else if (activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase)) url = endpoint.TrimEnd('/') + "/api/chat"; else url = endpoint.TrimEnd('/') + "/v1/chat/completions"; var json = JsonSerializer.Serialize(body); WorkflowLogService.LogLlmRawRequestFromContext(url, json); using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; await ApplyAuthHeaderAsync(req, ct); using var resp = await SendWithTlsAsync(req, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead); if (!resp.IsSuccessStatusCode) { var errBody = await resp.Content.ReadAsStringAsync(ct); var detail = ExtractErrorDetail(errBody); if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400) { var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false); var fallbackJson = JsonSerializer.Serialize(fallbackBody); WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson); using var retryReq = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json") }; await ApplyAuthHeaderAsync(retryReq, ct); using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead); if (!retryResp.IsSuccessStatusCode) throw new ToolCallNotSupportedException($"{activeService} API 오류 ({retryResp.StatusCode}): {detail}"); await foreach (var evt in StreamOpenAiToolEventsAsync(retryResp, true, prefetchToolCallAsync, ct).WithCancellation(ct)) yield return evt; yield break; } if ((int)resp.StatusCode == 400) throw new ToolCallNotSupportedException($"{activeService} API 오류 ({resp.StatusCode}): {detail}"); throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}"); } await foreach (var evt in StreamOpenAiToolEventsAsync(resp, isIbmDeployment, prefetchToolCallAsync, ct).WithCancellation(ct)) yield return evt; } private async IAsyncEnumerable StreamOpenAiToolEventsAsync( HttpResponseMessage resp, bool usesIbmDeploymentApi, Func>? prefetchToolCallAsync, [EnumeratorCancellation] CancellationToken ct) { // Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리 var contentType = resp.Content.Headers.ContentType?.MediaType ?? ""; if (!contentType.Contains("event-stream", StringComparison.OrdinalIgnoreCase) && !contentType.Contains("octet-stream", StringComparison.OrdinalIgnoreCase)) { // 비-SSE 전체 JSON 응답 (Ollama stream:false 등) var rawJson = await resp.Content.ReadAsStringAsync(ct); var respJson = ExtractJsonFromSseIfNeeded(rawJson); var trimmed = respJson.TrimStart(); if (trimmed.StartsWith('{') || trimmed.StartsWith('[')) { using var doc = JsonDocument.Parse(respJson); TryParseOpenAiUsage(doc.RootElement); if (TryExtractMessageToolBlocks(doc.RootElement, out var msgText, out var directBlocks)) { if (!string.IsNullOrWhiteSpace(msgText)) yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, msgText); foreach (var block in directBlocks) { if (prefetchToolCallAsync != null) block.PrefetchedExecutionTask = prefetchToolCallAsync(block); yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: block); } } yield return new ToolStreamEvent(ToolStreamEventKind.Completed); yield break; } } using var stream = await resp.Content.ReadAsStreamAsync(ct); using var reader = new StreamReader(stream); var firstChunkReceived = false; var toolAccumulators = new Dictionary(); var lastIbmGeneratedText = ""; while (!reader.EndOfStream && !ct.IsCancellationRequested) { var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout; var line = await ReadLineWithTimeoutAsync(reader, ct, timeout); if (line == null) { if (!firstChunkReceived) throw new TimeoutException("도구 호출 응답 첫 청크를 받지 못했습니다."); break; } if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ", StringComparison.Ordinal)) continue; firstChunkReceived = true; var data = line["data: ".Length..].Trim(); if (string.Equals(data, "[DONE]", StringComparison.OrdinalIgnoreCase)) break; using var doc = JsonDocument.Parse(data); var root = doc.RootElement; TryParseOpenAiUsage(root); if (usesIbmDeploymentApi && root.SafeTryGetProperty("status", out var statusEl) && string.Equals(statusEl.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase)) { var detail = root.SafeTryGetProperty("message", out var msgEl) ? msgEl.SafeGetString() : "IBM vLLM 도구 호출 응답 오류"; throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류"); } if (TryExtractMessageToolBlocks(root, out var messageText, out var directToolBlocks)) { if (!string.IsNullOrWhiteSpace(messageText)) yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, messageText); if (directToolBlocks.Count > 0) { foreach (var toolBlock in directToolBlocks) { if (prefetchToolCallAsync != null) toolBlock.PrefetchedExecutionTask = prefetchToolCallAsync(toolBlock); yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: toolBlock); } } continue; } if (usesIbmDeploymentApi && root.SafeTryGetProperty("results", out var resultsEl) && resultsEl.ValueKind == JsonValueKind.Array && resultsEl.GetArrayLength() > 0) { var first = resultsEl[0]; string? generatedText = null; if (first.SafeTryGetProperty("generated_text", out var generatedTextEl)) generatedText = TryExtractTextContent(generatedTextEl, out var extractedGeneratedText) ? extractedGeneratedText : generatedTextEl.SafeGetString(); else if (first.SafeTryGetProperty("output_text", out var outputTextEl)) generatedText = TryExtractTextContent(outputTextEl, out var extractedOutputText) ? extractedOutputText : outputTextEl.SafeGetString(); else if (first.SafeTryGetProperty("message", out var ibmMessageEl) && TryExtractMessageToolBlocks(ibmMessageEl, out var ibmMessageText, out var ibmToolBlocks)) { generatedText = ibmMessageText; foreach (var toolBlock in ibmToolBlocks) { if (prefetchToolCallAsync != null) toolBlock.PrefetchedExecutionTask = prefetchToolCallAsync(toolBlock); yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: toolBlock); } } if (!string.IsNullOrEmpty(generatedText)) { if (generatedText.StartsWith(lastIbmGeneratedText, StringComparison.Ordinal)) { var delta = generatedText[lastIbmGeneratedText.Length..]; if (!string.IsNullOrEmpty(delta)) yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, delta); lastIbmGeneratedText = generatedText; } else { if (!string.IsNullOrEmpty(generatedText)) yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, generatedText); lastIbmGeneratedText = generatedText; } } } if (root.SafeTryGetProperty("choices", out var choicesEl) && choicesEl.ValueKind == JsonValueKind.Array && choicesEl.GetArrayLength() > 0) { var firstChoice = choicesEl[0]; if (firstChoice.SafeTryGetProperty("delta", out var deltaEl)) { var emittedContent = false; if (deltaEl.SafeTryGetProperty("content", out var contentEl) && contentEl.ValueKind == JsonValueKind.String) { var chunk = contentEl.SafeGetString(); if (!string.IsNullOrEmpty(chunk)) { yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk); emittedContent = true; } } // Qwen3.5 thinking 모드 폴백: content가 비어있거나 없으면 reasoning_content 사용 // else if가 아닌 독립 if — content 키가 존재하되 빈 문자열("")인 경우도 커버 if (!emittedContent && deltaEl.SafeTryGetProperty("reasoning_content", out var reasoningEl) && reasoningEl.ValueKind == JsonValueKind.String) { var reasoningChunk = reasoningEl.SafeGetString(); if (!string.IsNullOrEmpty(reasoningChunk)) yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, reasoningChunk); } if (deltaEl.SafeTryGetProperty("tool_calls", out var toolCallsEl) && toolCallsEl.ValueKind == JsonValueKind.Array) { foreach (var toolCallEl in toolCallsEl.EnumerateArray()) { var index = toolCallEl.SafeTryGetProperty("index", out var indexEl) && indexEl.TryGetInt32(out var parsedIndex) ? parsedIndex : toolAccumulators.Count; if (!toolAccumulators.TryGetValue(index, out var acc)) { acc = new ToolCallAccumulator { Index = index }; toolAccumulators[index] = acc; } if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) acc.Id = idEl.SafeGetString() ?? acc.Id; if (toolCallEl.SafeTryGetProperty("function", out var functionEl)) { if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String) acc.Name = nameEl.SafeGetString() ?? acc.Name; if (functionEl.SafeTryGetProperty("arguments", out var argumentsEl)) { if (argumentsEl.ValueKind == JsonValueKind.String) acc.Arguments.Append(argumentsEl.SafeGetString()); else if (argumentsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array) acc.Arguments.Append(argumentsEl.GetRawText()); } } var emittedBlock = await TryCreateCompletedToolCallAsync(acc, prefetchToolCallAsync).ConfigureAwait(false); if (emittedBlock != null) yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: emittedBlock); } } } if (firstChoice.SafeTryGetProperty("message", out var messageEl)) { if (TryExtractMessageToolBlocks(messageEl, out var messageText2, out var directToolBlocks2)) { if (!string.IsNullOrWhiteSpace(messageText2)) yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, messageText2); if (directToolBlocks2.Count > 0) { foreach (var toolBlock in directToolBlocks2) { if (prefetchToolCallAsync != null) toolBlock.PrefetchedExecutionTask = prefetchToolCallAsync(toolBlock); yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: toolBlock); } } } } } } foreach (var acc in toolAccumulators.Values.OrderBy(a => a.Index)) { var emittedBlock = await TryCreateCompletedToolCallAsync(acc, prefetchToolCallAsync, forceEmit: true).ConfigureAwait(false); if (emittedBlock != null) yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: emittedBlock); } yield return new ToolStreamEvent(ToolStreamEventKind.Completed); } private static bool TryExtractMessageToolBlocks( JsonElement messageOrRoot, out string text, out List toolBlocks) { text = ""; toolBlocks = new List(); JsonElement message = messageOrRoot; if (messageOrRoot.SafeTryGetProperty("message", out var nestedMessage)) message = nestedMessage; var consumed = false; if (message.SafeTryGetProperty("content", out var contentEl)) { if (TryExtractTextContent(contentEl, out var parsedText)) { text = parsedText; consumed = true; } if (contentEl.ValueKind == JsonValueKind.Array) { foreach (var block in contentEl.EnumerateArray()) { if (!TryParseContentArrayToolBlock(block, out var toolBlock)) continue; toolBlocks.Add(toolBlock); consumed = true; } } } // Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용 if (!consumed && message.SafeTryGetProperty("reasoning_content", out var reasoningContentEl) && TryExtractTextContent(reasoningContentEl, out var reasoningText)) { text = reasoningText; consumed = true; } if (message.SafeTryGetProperty("tool_calls", out var toolCallsEl) && toolCallsEl.ValueKind == JsonValueKind.Array) { foreach (var tc in toolCallsEl.EnumerateArray()) { if (!tc.SafeTryGetProperty("function", out var functionEl)) continue; JsonElement? parsedArgs = null; if (functionEl.SafeTryGetProperty("arguments", out var argsEl)) { if (argsEl.ValueKind == JsonValueKind.String) { try { using var argsDoc = JsonDocument.Parse(argsEl.SafeGetString() ?? "{}"); parsedArgs = argsDoc.RootElement.Clone(); } catch { parsedArgs = null; } } else if (argsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { parsedArgs = argsEl.Clone(); } } toolBlocks.Add(new ContentBlock { Type = "tool_use", ToolName = functionEl.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "", ToolId = tc.SafeTryGetProperty("id", out var idEl) ? idEl.SafeGetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12], ToolInput = parsedArgs, }); } consumed = true; } return consumed; } private static bool TryExtractTextContent(JsonElement element, out string text) { text = ""; if (element.ValueKind == JsonValueKind.String) { var parsed = element.SafeGetString(); if (!string.IsNullOrWhiteSpace(parsed)) { text = parsed; return true; } return false; } if (element.ValueKind != JsonValueKind.Array) return false; var segments = new List(); foreach (var item in element.EnumerateArray()) { if (item.ValueKind == JsonValueKind.String) { var str = item.SafeGetString(); if (!string.IsNullOrWhiteSpace(str)) segments.Add(str); continue; } if (item.ValueKind != JsonValueKind.Object) continue; if (item.SafeTryGetProperty("text", out var textEl) && textEl.ValueKind == JsonValueKind.String) { var str = textEl.SafeGetString(); if (!string.IsNullOrWhiteSpace(str)) segments.Add(str); continue; } if (item.SafeTryGetProperty("content", out var nestedContentEl) && TryExtractTextContent(nestedContentEl, out var nestedText)) { segments.Add(nestedText); continue; } if (item.SafeTryGetProperty("reasoning_content", out var reasoningEl) && TryExtractTextContent(reasoningEl, out var reasoningText)) { segments.Add(reasoningText); continue; } if (item.SafeTryGetProperty("output_text", out var outputTextEl) && outputTextEl.ValueKind == JsonValueKind.String) { var str = outputTextEl.SafeGetString(); if (!string.IsNullOrWhiteSpace(str)) segments.Add(str); } } if (segments.Count == 0) return false; text = string.Join("\n", segments.Where(s => !string.IsNullOrWhiteSpace(s))); return !string.IsNullOrWhiteSpace(text); } private static bool TryParseContentArrayToolBlock(JsonElement block, out ContentBlock toolBlock) { toolBlock = default!; if (block.ValueKind != JsonValueKind.Object) return false; var type = block.SafeTryGetProperty("type", out var typeEl) ? typeEl.SafeGetString() ?? "" : ""; if (!string.Equals(type, "tool_use", StringComparison.OrdinalIgnoreCase) && !string.Equals(type, "tool_call", StringComparison.OrdinalIgnoreCase)) return false; JsonElement? parsedArgs = null; if (block.SafeTryGetProperty("input", out var inputEl)) parsedArgs = inputEl.Clone(); else if (block.SafeTryGetProperty("arguments", out var argsEl)) { if (argsEl.ValueKind == JsonValueKind.String) { try { using var argsDoc = JsonDocument.Parse(argsEl.SafeGetString() ?? "{}"); parsedArgs = argsDoc.RootElement.Clone(); } catch { parsedArgs = null; } } else if (argsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array) { parsedArgs = argsEl.Clone(); } } var toolName = block.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : ""; if (string.IsNullOrWhiteSpace(toolName)) return false; toolBlock = new ContentBlock { Type = "tool_use", ToolName = toolName, ToolId = block.SafeTryGetProperty("id", out var idEl) ? idEl.SafeGetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12], ToolInput = parsedArgs, }; return true; } private static bool LooksLikeCompleteJson(string json) { if (string.IsNullOrWhiteSpace(json)) return false; json = json.Trim(); if (!(json.StartsWith('{') || json.StartsWith('['))) return false; // 빠른 사전 검사: 중괄호/대괄호 균형이 맞지 않으면 파싱 시도 불필요 int depth = 0; bool inString = false; bool escape = false; for (int i = 0; i < json.Length; i++) { var ch = json[i]; if (escape) { escape = false; continue; } if (ch == '\\' && inString) { escape = true; continue; } if (ch == '"') { inString = !inString; continue; } if (inString) continue; if (ch is '{' or '[') depth++; else if (ch is '}' or ']') depth--; } if (depth != 0) return false; try { using var _ = JsonDocument.Parse(json); return true; } catch { return false; } } private static async Task TryCreateCompletedToolCallAsync( ToolCallAccumulator acc, Func>? prefetchToolCallAsync, bool forceEmit = false) { if (acc.Emitted || string.IsNullOrWhiteSpace(acc.Name)) return null; var argsJson = acc.Arguments.ToString().Trim(); JsonElement? parsedArgs = null; if (!string.IsNullOrEmpty(argsJson)) { if (!forceEmit && !LooksLikeCompleteJson(argsJson)) return null; try { using var argsDoc = JsonDocument.Parse(argsJson); parsedArgs = argsDoc.RootElement.Clone(); } catch { if (!forceEmit) return null; } } else if (!forceEmit) { // 스트리밍 중 이름만 도착하고 arguments가 아직 비어 있는 경우 // → 후속 청크에서 arguments가 올 수 있으므로 조기 방출하지 않음 return null; } var block = new ContentBlock { Type = "tool_use", ToolName = acc.Name, ToolId = string.IsNullOrWhiteSpace(acc.Id) ? Guid.NewGuid().ToString("N")[..12] : acc.Id, ToolInput = parsedArgs, }; if (prefetchToolCallAsync != null) block.PrefetchedExecutionTask = prefetchToolCallAsync(block); acc.Emitted = true; return block; } // ─── 공통 헬퍼 ───────────────────────────────────────────────────── /// /// tool_calls가 포함된 assistant 메시지 뒤에 대응하는 tool 메시지가 없으면 /// 해당 assistant 메시지를 일반 텍스트로 평탄화합니다. /// 컨텍스트 압축 후 쌍이 깨지는 경우를 방어합니다. /// private static void SanitizeToolCallPairs(List msgs) { // ── 1패스: tool_calls assistant 메시지의 쌍 검증 ── // tool_calls가 있는 assistant 인덱스를 기록하여 2패스에서 고아 tool 판별에 사용 var pairedToolIndices = new HashSet(); for (int i = 0; i < msgs.Count; i++) { var msgType = msgs[i].GetType(); var toolCallsProp = msgType.GetProperty("tool_calls"); var roleProp = msgType.GetProperty("role"); if (toolCallsProp == null || roleProp == null) continue; var role = roleProp.GetValue(msgs[i]) as string; if (role != "assistant") continue; var toolCalls = toolCallsProp.GetValue(msgs[i]); if (toolCalls == null) continue; int callCount = 0; if (toolCalls is System.Collections.ICollection col) callCount = col.Count; else if (toolCalls is System.Collections.IEnumerable en) { foreach (var _ in en) callCount++; } if (callCount == 0) continue; // 바로 다음에 tool 역할 메시지가 callCount개 있는지 확인 int foundTools = 0; for (int j = i + 1; j < msgs.Count && foundTools < callCount; j++) { var jType = msgs[j].GetType(); var jRole = jType.GetProperty("role")?.GetValue(msgs[j]) as string; if (jRole == "tool") { foundTools++; pairedToolIndices.Add(j); } else break; } if (foundTools < callCount) { // 쌍이 불완전 → assistant를 일반 텍스트로 교체 var contentProp = msgType.GetProperty("content"); var contentText = contentProp?.GetValue(msgs[i]) as string ?? ""; if (string.IsNullOrWhiteSpace(contentText)) contentText = "[이전 도구 호출 — 결과 누락으로 생략됨]"; msgs[i] = new { role = "assistant", content = contentText }; // 이 assistant에 딸린 불완전 tool 메시지도 user로 변환 for (int j = i + 1; j < msgs.Count; j++) { var jType = msgs[j].GetType(); var jRole = jType.GetProperty("role")?.GetValue(msgs[j]) as string; if (jRole != "tool") break; var jContent = jType.GetProperty("content")?.GetValue(msgs[j]) as string ?? ""; msgs[j] = new { role = "user", content = $"[이전 도구 결과]\n{jContent}" }; pairedToolIndices.Remove(j); } LogService.Info($"[ToolUse] tool_calls/tool 쌍 불일치 감지 — assistant 메시지를 평탄화 (index={i})"); } } // ── 2패스: 앞에 tool_calls가 없는 고아 tool 메시지를 user로 변환 ── for (int i = 0; i < msgs.Count; i++) { if (pairedToolIndices.Contains(i)) continue; var msgType = msgs[i].GetType(); var roleProp = msgType.GetProperty("role"); var role = roleProp?.GetValue(msgs[i]) as string; if (role != "tool") continue; var content = msgType.GetProperty("content")?.GetValue(msgs[i]) as string ?? ""; msgs[i] = new { role = "user", content = $"[이전 도구 결과]\n{content}" }; LogService.Info($"[ToolUse] 고아 tool 메시지 감지 — user로 변환 (index={i})"); } } /// ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함. private static object BuildPropertySchema(Agent.ToolProperty prop, bool upperCaseType) { var typeName = upperCaseType ? prop.Type.ToUpperInvariant() : prop.Type; if (prop.Type.Equals("array", StringComparison.OrdinalIgnoreCase) && prop.Items != null) { return new { type = typeName, description = prop.Description, items = BuildPropertySchema(prop.Items, upperCaseType) }; } // enum 값이 있으면 포함 if (prop.Enum is { Count: > 0 }) return new { type = typeName, description = prop.Description, @enum = prop.Enum }; return new { type = typeName, description = prop.Description }; } /// 에러 응답 본문에서 핵심 메시지를 추출합니다. private static string ExtractErrorDetail(string errBody) { if (string.IsNullOrWhiteSpace(errBody)) return "응답 없음"; try { using var doc = JsonDocument.Parse(errBody); // Ollama: {"error":"..."} if (doc.RootElement.SafeTryGetProperty("error", out var err)) { if (err.ValueKind == JsonValueKind.String) return err.SafeGetString() ?? errBody; if (err.ValueKind == JsonValueKind.Object && err.SafeTryGetProperty("message", out var m)) return m.SafeGetString() ?? errBody; } } catch { } // JSON 아니면 원본 (최대 500자) return errBody.Length > 500 ? errBody[..500] + "…" : errBody; } } /// 도구 호출 자체가 서버에서 거부된 경우 (400). 일반 텍스트 응답으로 폴백 시도 가능. public class ToolCallNotSupportedException : Exception { public ToolCallNotSupportedException(string message) : base(message) { } }