변경 목적: - AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다. - claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다. 핵심 수정사항: - 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다. - ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다. - Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다. - 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다. - 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
2103 lines
92 KiB
C#
2103 lines
92 KiB
C#
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;
|
|
|
|
/// <summary>LLM 응답에서 파싱된 컨텐츠 블록.</summary>
|
|
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<ToolPrefetchResult?>? 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);
|
|
|
|
/// <summary>
|
|
/// LlmService의 Function Calling (tool_use) 확장.
|
|
/// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다.
|
|
/// 기존 SendAsync/StreamAsync는 변경하지 않고, 에이전트 전용 메서드를 추가합니다.
|
|
/// </summary>
|
|
public partial class LlmService
|
|
{
|
|
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
|
|
/// <param name="forceToolCall">
|
|
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
|
|
/// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에 사용하세요.
|
|
/// Claude/Gemini는 지원하지 않으므로 vLLM/Ollama/OpenAI 계열에만 적용됩니다.
|
|
/// </param>
|
|
public async Task<List<ContentBlock>> SendWithToolsAsync(
|
|
List<ChatMessage> messages,
|
|
IReadOnlyCollection<IAgentTool> tools,
|
|
CancellationToken ct = default,
|
|
bool forceToolCall = false,
|
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? 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<ToolStreamEvent> StreamWithToolsAsync(
|
|
List<ChatMessage> messages,
|
|
IReadOnlyCollection<IAgentTool> tools,
|
|
bool forceToolCall = false,
|
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? 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 스트리밍을 지원하지 않습니다.");
|
|
}
|
|
}
|
|
|
|
/// <summary>도구 실행 결과를 LLM에 피드백하기 위한 메시지를 생성합니다.</summary>
|
|
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<List<ContentBlock>> SendSigmoidWithToolsAsync(
|
|
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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<ContentBlock>();
|
|
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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
|
|
{
|
|
var llm = _settings.Settings.Llm;
|
|
var msgs = new List<object>();
|
|
|
|
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<object>();
|
|
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<object>();
|
|
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<List<ContentBlock>> SendGeminiWithToolsAsync(
|
|
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> 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<ContentBlock>();
|
|
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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
|
|
{
|
|
var contents = new List<object>();
|
|
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<object>();
|
|
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<object> { 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<string, object>
|
|
{
|
|
["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<List<ContentBlock>> SendOpenAiWithToolsAsync(
|
|
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct,
|
|
bool forceToolCall = false,
|
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? 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);
|
|
|
|
if (isIbmDeployment)
|
|
{
|
|
IbmDiagInfo($"[IBM진단] ToolUse.Send: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}");
|
|
IbmDiagDebug($"[IBM진단] ToolUse.Send 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "…" : json)}");
|
|
}
|
|
|
|
// 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);
|
|
if (isIbmDeployment)
|
|
IbmDiagError($"[IBM진단] ToolUse.Send API 오류: HTTP {(int)resp.StatusCode}, body={errBody}");
|
|
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
|
|
|
|
if (forceToolCall && (int)resp.StatusCode == 400)
|
|
{
|
|
LogService.Warn(isIbmDeployment
|
|
? "[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."
|
|
: "[ToolUse] OpenAI 호환 경로에서 tool_choice가 거부되어 텍스트 지시 기반 강제 전략으로 재시도합니다.");
|
|
var fallbackBody = isIbmDeployment
|
|
? BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false)
|
|
: BuildOpenAiToolBody(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, isIbmDeployment, 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Qwen/vLLM 등이 tool_calls 대신 텍스트로 도구 호출을 출력하는 경우를 파싱합니다.
|
|
/// 지원 패턴:
|
|
/// 1. <tool_call>{"name":"...", "arguments":{...}}</tool_call>
|
|
/// 2. Qwen3 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call>
|
|
/// 3. 여러 건의 연속 tool_call 태그
|
|
/// </summary>
|
|
// ── 텍스트 폴백 파싱용 정규식 (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);
|
|
|
|
// 패턴 4: 파이프-래핑 커스텀 포맷 (FastAPI로 호스팅된 Gemma 계열, 일부 IBM/Kimi/GLM 배포에서 leak)
|
|
// 예: <|tool_call>call;document_read{path:<|"|>전략보고서.html<|"|>}<tool_call|>
|
|
// - 앞 여는 태그 `<|tool_call>` / 닫는 태그 `<tool_call|>` 혹은 `</tool_call|>`
|
|
// - 본문은 `call;NAME{args}` 또는 `NAME{args}` 형태
|
|
// - args 내부의 `<|"|>` 는 따옴표로 디코딩, 비인용 키는 따옴표 부여
|
|
private static readonly System.Text.RegularExpressions.Regex ToolCallPipeWrappedRegex = new(
|
|
@"<\|\s*tool_call\s*\|?>\s*(?:call\s*;\s*)?(\w+)\s*(\{[\s\S]*?\})\s*<\s*/?\s*tool_call\s*\|\s*>",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
private static readonly System.Text.RegularExpressions.Regex PipeQuoteDecodeRegex = new(
|
|
@"<\|\s*""\s*\|>",
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
private static readonly System.Text.RegularExpressions.Regex UnquotedJsonKeyRegex = new(
|
|
@"(?<=[\{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:",
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
internal static List<ContentBlock> TryExtractToolCallsFromText(string text)
|
|
{
|
|
var results = new List<ContentBlock>();
|
|
if (string.IsNullOrWhiteSpace(text)) return results;
|
|
|
|
// 패턴 1: <tool_call>...</tool_call> 태그 (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);
|
|
}
|
|
}
|
|
|
|
// 패턴 4: 파이프-래핑 커스텀 포맷 (<|tool_call>call;NAME{args}<tool_call|>)
|
|
if (results.Count == 0)
|
|
{
|
|
foreach (System.Text.RegularExpressions.Match m in ToolCallPipeWrappedRegex.Matches(text))
|
|
{
|
|
var name = m.Groups[1].Value;
|
|
var rawArgs = m.Groups[2].Value;
|
|
// `<|"|>` → `"` 디코딩
|
|
var decoded = PipeQuoteDecodeRegex.Replace(rawArgs, "\"");
|
|
// 비인용 키를 JSON 키로 변환 ({path:"x"} → {"path":"x"})
|
|
var normalized = UnquotedJsonKeyRegex.Replace(decoded, "\"$1\":");
|
|
var block = TryParseToolCallJsonWithName(name, normalized);
|
|
if (block != null) results.Add(block);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 텍스트에서 파싱된 tool_call 태그(4가지 형식 전부)를 제거합니다.
|
|
/// 폴백 파싱으로 도구 호출이 추출된 경우, 사용자 화면에 원본 토큰이 남지 않도록 최종 표시 텍스트를 정화.
|
|
/// </summary>
|
|
internal static string StripToolCallTokens(string text)
|
|
{
|
|
if (string.IsNullOrEmpty(text)) return text;
|
|
text = ToolCallTagRegex.Replace(text, "");
|
|
text = ToolCallFunctionRegex.Replace(text, "");
|
|
text = ToolCallJsonRegex.Replace(text, "");
|
|
text = ToolCallPipeWrappedRegex.Replace(text, "");
|
|
return text.Trim();
|
|
}
|
|
|
|
/// <summary>{"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환.</summary>
|
|
private static ContentBlock? TryParseToolCallJson(string json)
|
|
{
|
|
try
|
|
{
|
|
json = json.Trim();
|
|
// <tool_call> 태그 내용에서 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>이름과 arguments JSON이 별도로 주어진 경우.</summary>
|
|
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 sealed record OpenAiToolCompatibilityProfile(
|
|
int StructuredHistoryRecentWindow,
|
|
bool AllowParallelToolCalls,
|
|
bool IncludeReasoningEffort,
|
|
bool AddToolOnlyDirectiveOnFallback);
|
|
|
|
private OpenAiToolCompatibilityProfile GetOpenAiToolCompatibilityProfile(string service, string model)
|
|
{
|
|
var normalizedService = NormalizeServiceName(service);
|
|
var normalizedModel = (model ?? "").Trim().ToLowerInvariant();
|
|
var isFragileVllmFamily =
|
|
normalizedService == "vllm" &&
|
|
(normalizedModel.Contains("qwen", StringComparison.OrdinalIgnoreCase)
|
|
|| normalizedModel.Contains("llama", StringComparison.OrdinalIgnoreCase)
|
|
|| normalizedModel.Contains("deepseek", StringComparison.OrdinalIgnoreCase)
|
|
|| normalizedModel.Contains("mistral", StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (isFragileVllmFamily)
|
|
{
|
|
return new OpenAiToolCompatibilityProfile(
|
|
StructuredHistoryRecentWindow: 4,
|
|
AllowParallelToolCalls: false,
|
|
IncludeReasoningEffort: false,
|
|
AddToolOnlyDirectiveOnFallback: true);
|
|
}
|
|
|
|
return new OpenAiToolCompatibilityProfile(
|
|
StructuredHistoryRecentWindow: 8,
|
|
AllowParallelToolCalls: true,
|
|
IncludeReasoningEffort: true,
|
|
AddToolOnlyDirectiveOnFallback: true);
|
|
}
|
|
|
|
private static string BuildOpenAiToolOnlyDirective(IReadOnlyCollection<IAgentTool> tools)
|
|
{
|
|
var toolNames = string.Join(", ", tools.Select(t => t.Name).Take(12));
|
|
return "[TOOL_ONLY] 텍스트로 설명하지 말고 지금 바로 도구를 호출하세요. " +
|
|
$"사용 가능한 도구: {toolNames}. " +
|
|
"plain text 대신 function/tool call을 사용하세요.";
|
|
}
|
|
|
|
private object BuildOpenAiToolBody(
|
|
List<ChatMessage> messages,
|
|
IReadOnlyCollection<IAgentTool> tools,
|
|
bool forceToolCall = false,
|
|
bool useToolChoice = true)
|
|
{
|
|
var llm = _settings.Settings.Llm;
|
|
var activeService = ResolveService();
|
|
var activeModel = ResolveModel();
|
|
var compatibilityProfile = GetOpenAiToolCompatibilityProfile(activeService, activeModel);
|
|
var msgs = new List<object>();
|
|
var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages, compatibilityProfile.StructuredHistoryRecentWindow);
|
|
|
|
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<object>();
|
|
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<object>();
|
|
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 });
|
|
}
|
|
}
|
|
|
|
if (forceToolCall && !useToolChoice && compatibilityProfile.AddToolOnlyDirectiveOnFallback)
|
|
{
|
|
msgs.Add(new
|
|
{
|
|
role = "user",
|
|
content = BuildOpenAiToolOnlyDirective(tools),
|
|
});
|
|
}
|
|
|
|
// ── 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<string, object>
|
|
{
|
|
["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 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<string, object?>
|
|
{
|
|
["model"] = activeModel,
|
|
["messages"] = msgs,
|
|
["tools"] = toolDefs,
|
|
["stream"] = false,
|
|
["options"] = new { temperature = ResolveToolTemperature() }
|
|
};
|
|
// Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용
|
|
if (forceToolCall && useToolChoice)
|
|
ollamaBody["tool_choice"] = "required";
|
|
return ollamaBody;
|
|
}
|
|
|
|
var body = new Dictionary<string, object?>
|
|
{
|
|
["model"] = activeModel,
|
|
["messages"] = msgs,
|
|
["tools"] = toolDefs,
|
|
["stream"] = true,
|
|
["temperature"] = ResolveToolTemperature(),
|
|
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
|
|
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch && compatibilityProfile.AllowParallelToolCalls,
|
|
};
|
|
// 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환)
|
|
body["stream_options"] = new { include_usage = true };
|
|
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
|
|
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
|
|
if (forceToolCall && useToolChoice)
|
|
body["tool_choice"] = "required";
|
|
var effort = ResolveReasoningEffort();
|
|
if (compatibilityProfile.IncludeReasoningEffort && !string.IsNullOrWhiteSpace(effort))
|
|
body["reasoning_effort"] = effort;
|
|
return body;
|
|
}
|
|
|
|
private static int GetStructuredToolHistoryStartIndex(IReadOnlyList<ChatMessage> messages, int protectedRecentNonSystemMessages)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
|
|
private object BuildIbmToolBody(
|
|
List<ChatMessage> messages,
|
|
IReadOnlyCollection<IAgentTool> 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<object>();
|
|
|
|
// 시스템 프롬프트 + 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" +
|
|
"<tool_call>\n" +
|
|
"{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}\n" +
|
|
"</tool_call>\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 <tool_call> immediately.\n" +
|
|
"- If multiple tools are needed, output multiple <tool_call> 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] 설명하지 말고 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 평문 응답은 거부됩니다.\nExample: <tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}\n</tool_call>"
|
|
});
|
|
}
|
|
|
|
// OpenAI 호환 도구 정의 (형식 동일, watsonx에서 tools 필드 지원)
|
|
var toolDefs = tools.Select(t =>
|
|
{
|
|
var paramDict = new Dictionary<string, object>
|
|
{
|
|
["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: OpenAI 표준 필드만 전송.
|
|
// 이전에는 `tool_choice` + `tool_choice_option` 둘 다 보내 구버전 호환을 시도했지만,
|
|
// 최신 IBM vLLM 배포는 다음 오류로 요청을 거부합니다:
|
|
// "400 Json document validation error: tool_choice_option should not be defined if a value is given for ToolChoice"
|
|
// 구버전 배포(tool_choice_option 전용)는 상위 ToolCallNotSupportedException 폴백이 처리함.
|
|
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
|
|
if (forceToolCall && useToolChoice)
|
|
{
|
|
return new
|
|
{
|
|
messages = msgs,
|
|
tools = toolDefs,
|
|
tool_choice = "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<string>();
|
|
var toolSegments = new List<string>();
|
|
|
|
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($"<tool_call>\n{{\"name\":\"{name}\",\"arguments\":{args}}}\n</tool_call>");
|
|
}
|
|
|
|
var parts = new List<string>();
|
|
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
|
|
? "<tool_call>\n{\"name\":\"unknown_tool\",\"arguments\":{}}\n</tool_call>"
|
|
: string.Join("\n\n", parts);
|
|
}
|
|
|
|
private static string BuildOpenAiAssistantTranscript(JsonElement blocksArr)
|
|
{
|
|
var textSegments = new List<string>();
|
|
var toolNames = new List<string>();
|
|
|
|
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<string>();
|
|
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<List<ContentBlock>> ReadOpenAiToolBlocksFromStreamAsync(
|
|
HttpResponseMessage resp,
|
|
bool usesIbmDeploymentApi,
|
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
|
|
CancellationToken ct)
|
|
{
|
|
var blocks = new List<ContentBlock>();
|
|
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<ContentBlock>();
|
|
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);
|
|
}
|
|
// 사용자 화면에 원본 tool_call 토큰이 남지 않도록 텍스트 정화
|
|
var cleanedText = StripToolCallTokens(textBlock.Text);
|
|
result.Remove(textBlock);
|
|
if (!string.IsNullOrWhiteSpace(cleanedText))
|
|
result.Add(new ContentBlock { Type = "text", Text = cleanedText });
|
|
result.AddRange(extracted);
|
|
var toolNames = string.Join(", ", extracted.Select(e => e.ToolName));
|
|
IbmDiagInfo($"[IBM진단] 텍스트 폴백에서 도구 호출 {extracted.Count}건 추출: [{toolNames}]");
|
|
}
|
|
else if (usesIbmDeploymentApi)
|
|
{
|
|
var preview = textBlock.Text.Length > 300 ? textBlock.Text[..300] + "…" : textBlock.Text;
|
|
IbmDiagError($"[IBM진단] 응답에 tool_calls 없음, 텍스트 폴백 파싱도 실패. 응답 텍스트: {preview}");
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async IAsyncEnumerable<ToolStreamEvent> StreamOpenAiToolEventsAsync(
|
|
List<ChatMessage> messages,
|
|
IReadOnlyCollection<IAgentTool> tools,
|
|
[EnumeratorCancellation] CancellationToken ct,
|
|
bool forceToolCall = false,
|
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? 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);
|
|
if (isIbmDeployment)
|
|
{
|
|
IbmDiagInfo($"[IBM진단] ToolUse.Stream: url={url}, bodyLen={json.Length}자, tools={tools.Count}개, force={forceToolCall}");
|
|
IbmDiagDebug($"[IBM진단] ToolUse.Stream 요청본문(앞500자): {(json.Length > 500 ? json[..500] + "…" : json)}");
|
|
}
|
|
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)
|
|
IbmDiagError($"[IBM진단] ToolUse.Stream API 오류: HTTP {(int)resp.StatusCode}, body={errBody}");
|
|
if (forceToolCall && (int)resp.StatusCode == 400)
|
|
{
|
|
LogService.Warn(isIbmDeployment
|
|
? "[ToolUse] IBM 배포형 스트리밍 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."
|
|
: "[ToolUse] OpenAI 호환 스트리밍 경로에서 tool_choice가 거부되어 텍스트 지시 기반 강제 전략으로 재시도합니다.");
|
|
var fallbackBody = isIbmDeployment
|
|
? BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false)
|
|
: BuildOpenAiToolBody(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<ToolStreamEvent> StreamOpenAiToolEventsAsync(
|
|
HttpResponseMessage resp,
|
|
bool usesIbmDeploymentApi,
|
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
|
|
[EnumeratorCancellation] CancellationToken ct)
|
|
{
|
|
// Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리
|
|
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
|
|
if (usesIbmDeploymentApi)
|
|
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream: ContentType={contentType}");
|
|
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);
|
|
if (usesIbmDeploymentApi)
|
|
{
|
|
var preview = rawJson.Length > 500 ? rawJson[..500] + "…" : rawJson;
|
|
IbmDiagInfo($"[IBM진단] ToolUse 비-SSE 응답(전체 JSON): len={rawJson.Length}자\n 미리보기: {preview}");
|
|
}
|
|
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<int, ToolCallAccumulator>();
|
|
var lastIbmGeneratedText = "";
|
|
var ibmToolChunkCount = 0;
|
|
|
|
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))
|
|
{
|
|
if (usesIbmDeploymentApi)
|
|
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream 완료: 총 {ibmToolChunkCount}개 청크, toolAccumulators={toolAccumulators.Count}개");
|
|
break;
|
|
}
|
|
|
|
JsonDocument doc;
|
|
try
|
|
{
|
|
doc = JsonDocument.Parse(data);
|
|
}
|
|
catch (JsonException jex)
|
|
{
|
|
if (usesIbmDeploymentApi)
|
|
{
|
|
var preview = data.Length > 500 ? data[..500] + "…" : data;
|
|
IbmDiagError($"[IBM진단] ToolUse.ParseStream JSON 파싱 실패: {jex.Message}\n 원본: {preview}");
|
|
}
|
|
continue;
|
|
}
|
|
using (doc)
|
|
{
|
|
var root = doc.RootElement;
|
|
TryParseOpenAiUsage(root);
|
|
|
|
if (usesIbmDeploymentApi)
|
|
{
|
|
ibmToolChunkCount++;
|
|
if (ibmToolChunkCount <= 3 || ibmToolChunkCount % 50 == 0)
|
|
{
|
|
var preview = data.Length > 300 ? data[..300] + "…" : data;
|
|
IbmDiagDebug($"[IBM진단] ToolUse.ParseStream chunk#{ibmToolChunkCount}: {preview}");
|
|
}
|
|
}
|
|
|
|
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 도구 호출 응답 오류";
|
|
IbmDiagError($"[IBM진단] ToolUse.ParseStream 서버 오류: {detail}");
|
|
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;
|
|
}
|
|
|
|
// IBM vLLM은 첫 청크 이후 후속 delta에서 id/name을 ""(빈 문자열)로 다시 보내는 경우가 있음.
|
|
// 빈 값으로 덮어쓰면 누적된 name/id가 사라져 TryCreateCompletedToolCallAsync의
|
|
// IsNullOrWhiteSpace(acc.Name) 체크에 걸려 도구 호출이 방출되지 않는다.
|
|
// → 비-공백 값일 때만 갱신한다.
|
|
if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
|
|
{
|
|
var idStr = idEl.SafeGetString();
|
|
if (!string.IsNullOrWhiteSpace(idStr))
|
|
acc.Id = idStr;
|
|
}
|
|
|
|
if (toolCallEl.SafeTryGetProperty("function", out var functionEl))
|
|
{
|
|
if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
|
|
{
|
|
var nameStr = nameEl.SafeGetString();
|
|
if (!string.IsNullOrWhiteSpace(nameStr))
|
|
acc.Name = nameStr;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} // using (doc)
|
|
}
|
|
|
|
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<ContentBlock> toolBlocks)
|
|
{
|
|
text = "";
|
|
toolBlocks = new List<ContentBlock>();
|
|
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<string>();
|
|
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<ContentBlock?> TryCreateCompletedToolCallAsync(
|
|
ToolCallAccumulator acc,
|
|
Func<ContentBlock, Task<ToolPrefetchResult?>>? 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;
|
|
}
|
|
|
|
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// tool_calls가 포함된 assistant 메시지 뒤에 대응하는 tool 메시지가 없으면
|
|
/// 해당 assistant 메시지를 일반 텍스트로 평탄화합니다.
|
|
/// 컨텍스트 압축 후 쌍이 깨지는 경우를 방어합니다.
|
|
/// </summary>
|
|
private static void SanitizeToolCallPairs(List<object> msgs)
|
|
{
|
|
// ── 1패스: tool_calls assistant 메시지의 쌍 검증 ──
|
|
// tool_calls가 있는 assistant 인덱스를 기록하여 2패스에서 고아 tool 판별에 사용
|
|
var pairedToolIndices = new HashSet<int>();
|
|
|
|
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})");
|
|
}
|
|
}
|
|
|
|
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>
|
|
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 };
|
|
}
|
|
|
|
/// <summary>에러 응답 본문에서 핵심 메시지를 추출합니다.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>도구 호출 자체가 서버에서 거부된 경우 (400). 일반 텍스트 응답으로 폴백 시도 가능.</summary>
|
|
public class ToolCallNotSupportedException : Exception
|
|
{
|
|
public ToolCallNotSupportedException(string message) : base(message) { }
|
|
}
|