모델 프로파일 기반 Cowork/Code 루프와 진행 UX 고도화 반영
- 등록 모델 실행 프로파일을 검증 게이트, 문서 fallback, post-tool verification까지 확장 적용 - Cowork/Code 진행 카드에 계획/도구/검증/압축/폴백/재시도 단계 메타를 추가해 대기 상태 가시성 강화 - OpenAI/vLLM tool 요청에 병렬 도구 호출 힌트를 추가하고 회귀 프롬프트 문서를 프로파일 기준으로 전면 정리 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
This commit is contained in:
@@ -25,10 +25,16 @@ 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)
|
||||
CancellationToken ct = default,
|
||||
bool forceToolCall = false)
|
||||
{
|
||||
var activeService = ResolveService();
|
||||
EnsureOperationModeAllowsLlmService(activeService);
|
||||
@@ -36,7 +42,7 @@ public partial class LlmService
|
||||
{
|
||||
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
|
||||
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct),
|
||||
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct),
|
||||
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall),
|
||||
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
|
||||
};
|
||||
}
|
||||
@@ -428,7 +434,8 @@ public partial class LlmService
|
||||
// ─── OpenAI Compatible (Ollama / vLLM) Function Calling ──────────
|
||||
|
||||
private async Task<List<ContentBlock>> SendOpenAiWithToolsAsync(
|
||||
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct)
|
||||
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct,
|
||||
bool forceToolCall = false)
|
||||
{
|
||||
var activeService = ResolveService();
|
||||
|
||||
@@ -438,17 +445,19 @@ public partial class LlmService
|
||||
? ResolveEndpointForService(activeService)
|
||||
: resolvedEp;
|
||||
var registered = GetActiveRegisteredModel();
|
||||
if (UsesIbmDeploymentChatApi(activeService, registered, endpoint))
|
||||
{
|
||||
throw new ToolCallNotSupportedException(
|
||||
"IBM 배포형 vLLM 연결은 OpenAI 도구 호출 형식과 다를 수 있어 일반 대화 경로로 폴백합니다.");
|
||||
}
|
||||
var isIbmDeployment = UsesIbmDeploymentChatApi(activeService, registered, endpoint);
|
||||
|
||||
var body = BuildOpenAiToolBody(messages, tools);
|
||||
var body = isIbmDeployment
|
||||
? BuildIbmToolBody(messages, tools, forceToolCall)
|
||||
: BuildOpenAiToolBody(messages, tools, forceToolCall);
|
||||
|
||||
var url = activeService.ToLowerInvariant() == "ollama"
|
||||
? endpoint.TrimEnd('/') + "/api/chat"
|
||||
: endpoint.TrimEnd('/') + "/v1/chat/completions";
|
||||
string url;
|
||||
if (isIbmDeployment)
|
||||
url = BuildIbmDeploymentChatUrl(endpoint, stream: false);
|
||||
else if (activeService.ToLowerInvariant() == "ollama")
|
||||
url = endpoint.TrimEnd('/') + "/api/chat";
|
||||
else
|
||||
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
|
||||
var json = JsonSerializer.Serialize(body);
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
@@ -473,7 +482,19 @@ public partial class LlmService
|
||||
throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}");
|
||||
}
|
||||
|
||||
var respJson = await resp.Content.ReadAsStringAsync(ct);
|
||||
var rawResp = await resp.Content.ReadAsStringAsync(ct);
|
||||
|
||||
// SSE 형식 응답 사전 처리 (stream:false 요청에도 SSE로 응답하는 경우)
|
||||
var respJson = ExtractJsonFromSseIfNeeded(rawResp);
|
||||
|
||||
// 비-JSON 응답(IBM 도구 호출 미지원 등) → ToolCallNotSupportedException으로 폴백 트리거
|
||||
{
|
||||
var trimmedResp = respJson.TrimStart();
|
||||
if (!trimmedResp.StartsWith('{') && !trimmedResp.StartsWith('['))
|
||||
throw new ToolCallNotSupportedException(
|
||||
$"vLLM 응답이 JSON이 아닙니다 (도구 호출 미지원 가능성): {respJson[..Math.Min(120, respJson.Length)]}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(respJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
@@ -540,7 +561,7 @@ public partial class LlmService
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
|
||||
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
|
||||
{
|
||||
var llm = _settings.Settings.Llm;
|
||||
var msgs = new List<object>();
|
||||
@@ -648,6 +669,7 @@ public partial class LlmService
|
||||
|
||||
var activeService = ResolveService();
|
||||
var activeModel = ResolveModel();
|
||||
var executionPolicy = GetActiveExecutionPolicy();
|
||||
var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase);
|
||||
if (isOllama)
|
||||
{
|
||||
@@ -657,7 +679,7 @@ public partial class LlmService
|
||||
messages = msgs,
|
||||
tools = toolDefs,
|
||||
stream = false,
|
||||
options = new { temperature = ResolveTemperature() }
|
||||
options = new { temperature = ResolveToolTemperature() }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -667,15 +689,150 @@ public partial class LlmService
|
||||
["messages"] = msgs,
|
||||
["tools"] = toolDefs,
|
||||
["stream"] = false,
|
||||
["temperature"] = ResolveTemperature(),
|
||||
["temperature"] = ResolveToolTemperature(),
|
||||
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
|
||||
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
|
||||
};
|
||||
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
|
||||
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
|
||||
if (forceToolCall)
|
||||
body["tool_choice"] = "required";
|
||||
var effort = ResolveReasoningEffort();
|
||||
if (!string.IsNullOrWhiteSpace(effort))
|
||||
body["reasoning_effort"] = effort;
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
|
||||
private object BuildIbmToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
|
||||
{
|
||||
var msgs = new List<object>();
|
||||
|
||||
// 시스템 프롬프트
|
||||
var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt;
|
||||
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
||||
msgs.Add(new { role = "system", content = systemPrompt });
|
||||
|
||||
foreach (var m in messages)
|
||||
{
|
||||
if (m.Role == "system") continue;
|
||||
|
||||
// tool_result → OpenAI role:"tool" 형식 (watsonx /text/chat 지원)
|
||||
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 = "tool",
|
||||
tool_call_id = root.GetProperty("tool_use_id").GetString(),
|
||||
content = root.GetProperty("content").GetString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// _tool_use_blocks → OpenAI tool_calls 형식
|
||||
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");
|
||||
var textContent = "";
|
||||
var toolCallsList = new List<object>();
|
||||
foreach (var b in blocksArr.EnumerateArray())
|
||||
{
|
||||
var bType = b.GetProperty("type").GetString();
|
||||
if (bType == "text")
|
||||
textContent = b.GetProperty("text").GetString() ?? "";
|
||||
else if (bType == "tool_use")
|
||||
{
|
||||
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
|
||||
toolCallsList.Add(new
|
||||
{
|
||||
id = b.GetProperty("id").GetString() ?? "",
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = b.GetProperty("name").GetString() ?? "",
|
||||
arguments = argsJson,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
msgs.Add(new
|
||||
{
|
||||
role = "assistant",
|
||||
content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent,
|
||||
tool_calls = toolCallsList,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
msgs.Add(new { role = m.Role == "assistant" ? "assistant" : "user", content = m.Content });
|
||||
}
|
||||
|
||||
// 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: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
|
||||
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
|
||||
if (forceToolCall)
|
||||
{
|
||||
return new
|
||||
{
|
||||
messages = msgs,
|
||||
tools = toolDefs,
|
||||
tool_choice = "required",
|
||||
parameters = new
|
||||
{
|
||||
temperature = ResolveTemperature(),
|
||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
messages = msgs,
|
||||
tools = toolDefs,
|
||||
parameters = new
|
||||
{
|
||||
temperature = ResolveTemperature(),
|
||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>
|
||||
|
||||
Reference in New Issue
Block a user