모델 프로파일 기반 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:
2026-04-08 13:41:57 +09:00
parent b391dfdfb3
commit a2c952879d
552 changed files with 8094 additions and 13595 deletions

View File

@@ -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>