mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
Improved the Web Based Harness
This commit is contained in:
parent
c3c57c058a
commit
ee6eb84bd8
19 changed files with 2799 additions and 2202 deletions
36
DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs
Normal file
36
DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Api;
|
||||||
|
|
||||||
|
public static class MccPlaygroundEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapMccPlaygroundEndpoints(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
RouteGroupBuilder api = endpoints.MapGroup("/api");
|
||||||
|
|
||||||
|
api.MapGet("/health", () => Results.Ok(new { ok = true }));
|
||||||
|
|
||||||
|
api.MapGet("/config", (IOptions<MccWebHarnessOptions> options) =>
|
||||||
|
{
|
||||||
|
MccWebHarnessOptions harnessOptions = options.Value;
|
||||||
|
return Results.Ok(new MccConfigResponse(
|
||||||
|
Model: harnessOptions.ResolveModel(),
|
||||||
|
OpenRouterBaseUrl: harnessOptions.ResolveOpenRouterBaseUrl(),
|
||||||
|
McpEndpoint: harnessOptions.ResolveMcpEndpoint(),
|
||||||
|
HasApiKey: harnessOptions.HasApiKeyConfigured(),
|
||||||
|
ExposeInventoryWindowAction: harnessOptions.ExposeInventoryWindowAction,
|
||||||
|
ExposeInternalCommandTool: harnessOptions.ExposeInternalCommandTool));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/chat/stream", (ChatStreamRequest request, IMccAgentRunService runService, HttpContext httpContext, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
return TypedResults.ServerSentEvents(runService.StreamAsync(request, httpContext, cancellationToken));
|
||||||
|
})
|
||||||
|
.WithRequestTimeout("mcc-stream");
|
||||||
|
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs
Normal file
94
DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Contracts;
|
||||||
|
|
||||||
|
public sealed class ChatStreamRequest
|
||||||
|
{
|
||||||
|
public List<ChatMessage>? Messages { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ChatMessage
|
||||||
|
{
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MccConfigResponse(
|
||||||
|
string? Model,
|
||||||
|
string OpenRouterBaseUrl,
|
||||||
|
string McpEndpoint,
|
||||||
|
bool HasApiKey,
|
||||||
|
bool ExposeInventoryWindowAction,
|
||||||
|
bool ExposeInternalCommandTool);
|
||||||
|
|
||||||
|
public sealed record MccStreamEnvelope(string RunId, long Sequence, string Kind, object Data);
|
||||||
|
|
||||||
|
public sealed record MccRunStartedData(string Model, string McpEndpoint, DateTimeOffset StartedAtUtc);
|
||||||
|
|
||||||
|
public sealed record MccGuidanceLoadedData(
|
||||||
|
string SourceTool,
|
||||||
|
string CanonicalPromptName,
|
||||||
|
string GuidanceVersion,
|
||||||
|
MccCapabilityStatus CapabilityStatus);
|
||||||
|
|
||||||
|
public sealed record MccStateSummaryData(
|
||||||
|
int TurnCount,
|
||||||
|
int ToolCallCount,
|
||||||
|
bool SoftFinish,
|
||||||
|
int DirectAnswerAttempts,
|
||||||
|
IReadOnlyList<MccVerificationObligationView> OpenVerification,
|
||||||
|
IReadOnlyList<MccEvidenceView> RecentEvidence,
|
||||||
|
string? CompactionSummary);
|
||||||
|
|
||||||
|
public sealed record MccToolCalledData(string CallId, string Name, string ArgumentsJson, bool Advanced, bool Sensitive);
|
||||||
|
|
||||||
|
public sealed record MccToolResultData(
|
||||||
|
string CallId,
|
||||||
|
string Name,
|
||||||
|
bool IsError,
|
||||||
|
bool Success,
|
||||||
|
string? ErrorCode,
|
||||||
|
string Summary,
|
||||||
|
string RawText,
|
||||||
|
string EvidenceId);
|
||||||
|
|
||||||
|
public sealed record MccVerificationEventData(string ObligationId, string ToolName, string Kind, string Description);
|
||||||
|
|
||||||
|
public sealed record MccBudgetData(
|
||||||
|
int TurnCount,
|
||||||
|
int MaxTurns,
|
||||||
|
int ToolCallCount,
|
||||||
|
int MaxToolCalls,
|
||||||
|
double ElapsedSeconds,
|
||||||
|
int MaxWallClockSeconds);
|
||||||
|
|
||||||
|
public sealed record MccErrorData(string Code, string Message, string? Detail = null);
|
||||||
|
|
||||||
|
public sealed record MccFinalPayload(
|
||||||
|
string Status,
|
||||||
|
string Headline,
|
||||||
|
string AnswerMarkdown,
|
||||||
|
IReadOnlyList<string> VerifiedFacts,
|
||||||
|
IReadOnlyList<string> OpenIssues,
|
||||||
|
IReadOnlyList<string> EvidenceIds,
|
||||||
|
string? NextAction);
|
||||||
|
|
||||||
|
public sealed record MccSubmitFinalArgs(
|
||||||
|
string Status,
|
||||||
|
string Headline,
|
||||||
|
string AnswerMarkdown,
|
||||||
|
IReadOnlyList<string> VerifiedFacts,
|
||||||
|
IReadOnlyList<string> OpenIssues,
|
||||||
|
IReadOnlyList<string> EvidenceIds,
|
||||||
|
string? NextAction);
|
||||||
|
|
||||||
|
public sealed record MccCapabilityStatus(
|
||||||
|
[property: JsonPropertyName("sessionStatus")] bool SessionStatus,
|
||||||
|
[property: JsonPropertyName("chatAndCommands")] bool ChatAndCommands,
|
||||||
|
[property: JsonPropertyName("movement")] bool Movement,
|
||||||
|
[property: JsonPropertyName("inventory")] bool Inventory,
|
||||||
|
[property: JsonPropertyName("entityWorld")] bool EntityWorld);
|
||||||
|
|
||||||
|
public sealed record MccEvidenceView(string Id, string ToolName, string Summary, bool IsError);
|
||||||
|
|
||||||
|
public sealed record MccVerificationObligationView(string Id, string ToolName, string Kind, string Description);
|
||||||
852
DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs
Normal file
852
DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs
Normal file
|
|
@ -0,0 +1,852 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Net.ServerSentEvents;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
public interface IMccAgentRunService
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(ChatStreamRequest request, HttpContext httpContext, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MccAgentRunService : IMccAgentRunService
|
||||||
|
{
|
||||||
|
private readonly MccMcpSessionFactory sessionFactory;
|
||||||
|
private readonly MccGuidanceSource guidanceSource;
|
||||||
|
private readonly MccPromptComposer promptComposer;
|
||||||
|
private readonly MccContextCompressor contextCompressor;
|
||||||
|
private readonly MccFinalizer finalizer;
|
||||||
|
private readonly OpenRouterChatClient openRouterChatClient;
|
||||||
|
private readonly MccWebHarnessOptions options;
|
||||||
|
|
||||||
|
public MccAgentRunService(
|
||||||
|
MccMcpSessionFactory sessionFactory,
|
||||||
|
MccGuidanceSource guidanceSource,
|
||||||
|
MccPromptComposer promptComposer,
|
||||||
|
MccContextCompressor contextCompressor,
|
||||||
|
MccFinalizer finalizer,
|
||||||
|
OpenRouterChatClient openRouterChatClient,
|
||||||
|
IOptions<MccWebHarnessOptions> options)
|
||||||
|
{
|
||||||
|
this.sessionFactory = sessionFactory;
|
||||||
|
this.guidanceSource = guidanceSource;
|
||||||
|
this.promptComposer = promptComposer;
|
||||||
|
this.contextCompressor = contextCompressor;
|
||||||
|
this.finalizer = finalizer;
|
||||||
|
this.openRouterChatClient = openRouterChatClient;
|
||||||
|
this.options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(
|
||||||
|
ChatStreamRequest request,
|
||||||
|
HttpContext httpContext,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, httpContext.RequestAborted);
|
||||||
|
CancellationToken linkedToken = linkedCts.Token;
|
||||||
|
|
||||||
|
string runId = Guid.NewGuid().ToString("n");
|
||||||
|
long sequence = 0;
|
||||||
|
|
||||||
|
string? model = options.ResolveModel();
|
||||||
|
if (string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_MODEL or MccWebHarness:Model must be configured."));
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.HasApiKeyConfigured())
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_API_KEY is not set."));
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<object> baseConversationMessages = NormalizeConversation(request.Messages);
|
||||||
|
string userRequest = ExtractUserRequest(request.Messages);
|
||||||
|
if (string.IsNullOrWhiteSpace(userRequest))
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("invalid_request", "No user message was provided."));
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using McpClient client = await sessionFactory.CreateAsync(linkedToken);
|
||||||
|
MccGuidanceBundle guidance = await guidanceSource.LoadAsync(client, linkedToken);
|
||||||
|
|
||||||
|
MccRunState runState = new()
|
||||||
|
{
|
||||||
|
RunId = runId,
|
||||||
|
UserRequest = userRequest,
|
||||||
|
BaseConversationMessages = baseConversationMessages,
|
||||||
|
ConfiguredModel = model,
|
||||||
|
Guidance = guidance
|
||||||
|
};
|
||||||
|
|
||||||
|
yield return CreateEvent(runId, ref sequence, "run_started", new MccRunStartedData(model, options.ResolveMcpEndpoint(), runState.StartedAtUtc));
|
||||||
|
yield return CreateEvent(runId, ref sequence, "guidance_loaded", new MccGuidanceLoadedData(
|
||||||
|
guidance.SourceToolName,
|
||||||
|
guidance.CanonicalPromptName,
|
||||||
|
guidance.GuidanceVersion,
|
||||||
|
guidance.CapabilityStatus));
|
||||||
|
|
||||||
|
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: linkedToken);
|
||||||
|
MccToolCatalog catalog = MccToolPolicy.BuildCatalog(tools, options, finalizer.BuildSubmitToolSchema());
|
||||||
|
|
||||||
|
while (!linkedToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
runState.TurnCount++;
|
||||||
|
contextCompressor.CompactIfNeeded(runState);
|
||||||
|
yield return CreateEvent(runId, ref sequence, "state_summary", BuildStateSummary(runState, options));
|
||||||
|
|
||||||
|
if (runState.IsSoftFinish(options, DateTimeOffset.UtcNow))
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "budget", BuildBudgetData(runState));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runState.IsHardStop(options, DateTimeOffset.UtcNow))
|
||||||
|
break;
|
||||||
|
|
||||||
|
MccModelTurn? turn = null;
|
||||||
|
Exception? providerException = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
turn = await openRouterChatClient.CreateTurnAsync(
|
||||||
|
promptComposer.Compose(runState),
|
||||||
|
catalog.ModelVisibleTools,
|
||||||
|
options,
|
||||||
|
linkedToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
providerException = ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerException is not null || turn is null)
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("provider_error", "OpenRouter request failed.", providerException?.Message));
|
||||||
|
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
runState.RoutedModel = turn.ModelId;
|
||||||
|
runState.RoutedProvider = turn.RoutedProvider;
|
||||||
|
|
||||||
|
if (turn.ToolCalls.Count == 0)
|
||||||
|
{
|
||||||
|
runState.DirectAnswerAttempts++;
|
||||||
|
string content = string.IsNullOrWhiteSpace(turn.AssistantContent) ? "(empty assistant turn)" : turn.AssistantContent.Trim();
|
||||||
|
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["role"] = "assistant",
|
||||||
|
["content"] = content
|
||||||
|
});
|
||||||
|
|
||||||
|
if (runState.DirectAnswerAttempts >= 4)
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData(
|
||||||
|
"model_protocol_error",
|
||||||
|
"The model kept returning plain assistant text instead of using tools or mcc_submit_final.",
|
||||||
|
content));
|
||||||
|
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["role"] = "user",
|
||||||
|
["content"] = "The previous plain assistant text was not accepted by this harness. On your next turn, you must either call the relevant MCC tools or call mcc_submit_final. Do not answer with plain assistant text again."
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, object?> assistantMessage = new()
|
||||||
|
{
|
||||||
|
["role"] = "assistant",
|
||||||
|
["content"] = turn.AssistantContent,
|
||||||
|
["tool_calls"] = turn.ToolCalls.Select(call => new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = call.CallId,
|
||||||
|
["type"] = "function",
|
||||||
|
["function"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["name"] = call.Name,
|
||||||
|
["arguments"] = call.ArgumentsJson
|
||||||
|
}
|
||||||
|
}).ToArray()
|
||||||
|
};
|
||||||
|
runState.ToolConversationMessages.Add(assistantMessage);
|
||||||
|
|
||||||
|
foreach (MccModelToolCall toolCall in turn.ToolCalls)
|
||||||
|
{
|
||||||
|
MccToolProfile profile = MccToolPolicy.GetProfile(toolCall.Name);
|
||||||
|
yield return CreateEvent(runId, ref sequence, "tool_called", new MccToolCalledData(
|
||||||
|
toolCall.CallId,
|
||||||
|
toolCall.Name,
|
||||||
|
toolCall.ArgumentsJson,
|
||||||
|
profile.Risk == MccToolRisk.EscapeHatch,
|
||||||
|
profile.Risk == MccToolRisk.Sensitive));
|
||||||
|
|
||||||
|
if (toolCall.Name.Equals("mcc_submit_final", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
MccFinalizationValidation validation = finalizer.Validate(runState, toolCall.ArgumentsJson);
|
||||||
|
if (validation.Accepted)
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "final", validation.Payload!);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string localResultText = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
success = false,
|
||||||
|
errorCode = "invalid_final_submission",
|
||||||
|
message = validation.ErrorText
|
||||||
|
});
|
||||||
|
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
|
||||||
|
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||||
|
toolCall.CallId,
|
||||||
|
toolCall.Name,
|
||||||
|
IsError: true,
|
||||||
|
Success: false,
|
||||||
|
ErrorCode: "invalid_final_submission",
|
||||||
|
Summary: validation.ErrorText ?? "Invalid final submission.",
|
||||||
|
RawText: localResultText,
|
||||||
|
EvidenceId: string.Empty));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MccToolPolicy.RequiresExplicitUserIntent(toolCall.Name) && !MccToolPolicy.HasExplicitUserIntent(runState.UserRequest, toolCall.Name))
|
||||||
|
{
|
||||||
|
string localResultText = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
success = false,
|
||||||
|
errorCode = "explicit_user_intent_required",
|
||||||
|
message = $"Tool '{toolCall.Name}' requires explicit user intent."
|
||||||
|
});
|
||||||
|
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
|
||||||
|
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||||
|
toolCall.CallId,
|
||||||
|
toolCall.Name,
|
||||||
|
IsError: true,
|
||||||
|
Success: false,
|
||||||
|
ErrorCode: "explicit_user_intent_required",
|
||||||
|
Summary: $"Tool '{toolCall.Name}' requires explicit user intent.",
|
||||||
|
RawText: localResultText,
|
||||||
|
EvidenceId: string.Empty));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!catalog.ToolsByName.TryGetValue(toolCall.Name, out MccToolCatalogEntry? entry))
|
||||||
|
{
|
||||||
|
string unknownToolText = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
success = false,
|
||||||
|
errorCode = "unknown_tool",
|
||||||
|
message = $"Unknown tool '{toolCall.Name}'."
|
||||||
|
});
|
||||||
|
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, unknownToolText));
|
||||||
|
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||||
|
toolCall.CallId,
|
||||||
|
toolCall.Name,
|
||||||
|
IsError: true,
|
||||||
|
Success: false,
|
||||||
|
ErrorCode: "unknown_tool",
|
||||||
|
Summary: $"Unknown tool '{toolCall.Name}'.",
|
||||||
|
RawText: unknownToolText,
|
||||||
|
EvidenceId: string.Empty));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
CallToolResult? result = null;
|
||||||
|
Exception? toolException = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Dictionary<string, object?> arguments = MccJsonArguments.Parse(toolCall.ArgumentsJson);
|
||||||
|
result = await client.CallToolAsync(toolCall.Name, arguments, cancellationToken: linkedToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
toolException = ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolException is not null || result is null)
|
||||||
|
{
|
||||||
|
string failedText = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
success = false,
|
||||||
|
errorCode = "tool_call_failed",
|
||||||
|
message = toolException?.Message
|
||||||
|
});
|
||||||
|
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, failedText));
|
||||||
|
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||||
|
toolCall.CallId,
|
||||||
|
toolCall.Name,
|
||||||
|
IsError: true,
|
||||||
|
Success: false,
|
||||||
|
ErrorCode: "tool_call_failed",
|
||||||
|
Summary: toolException?.Message ?? "Tool call failed.",
|
||||||
|
RawText: failedText,
|
||||||
|
EvidenceId: string.Empty));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
runState.ToolCallCount++;
|
||||||
|
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
|
||||||
|
MccEvidenceRecord evidence = CreateEvidence(runState, toolCall.Name, normalized);
|
||||||
|
runState.Evidence.Add(evidence);
|
||||||
|
runState.ToolExecutions.Add(new MccToolExecutionRecord
|
||||||
|
{
|
||||||
|
CallId = toolCall.CallId,
|
||||||
|
ToolName = toolCall.Name,
|
||||||
|
ArgumentsJson = toolCall.ArgumentsJson,
|
||||||
|
Evidence = evidence
|
||||||
|
});
|
||||||
|
|
||||||
|
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, normalized.Text));
|
||||||
|
|
||||||
|
foreach (MccVerificationObligation obligation in CreateObligations(runState, evidence, toolCall.ArgumentsJson))
|
||||||
|
{
|
||||||
|
runState.VerificationObligations.Add(obligation);
|
||||||
|
yield return CreateEvent(runId, ref sequence, "verification_required", new MccVerificationEventData(
|
||||||
|
obligation.Id,
|
||||||
|
obligation.ToolName,
|
||||||
|
obligation.Kind,
|
||||||
|
obligation.Description));
|
||||||
|
|
||||||
|
if (obligation.Cleared)
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
|
||||||
|
obligation.Id,
|
||||||
|
obligation.ToolName,
|
||||||
|
obligation.Kind,
|
||||||
|
obligation.Description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (MccVerificationObligation cleared in TryClearObligationsFromEvidence(runState, evidence))
|
||||||
|
{
|
||||||
|
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
|
||||||
|
cleared.Id,
|
||||||
|
cleared.ToolName,
|
||||||
|
cleared.Kind,
|
||||||
|
cleared.Description));
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||||
|
toolCall.CallId,
|
||||||
|
toolCall.Name,
|
||||||
|
evidence.IsError,
|
||||||
|
evidence.Success,
|
||||||
|
evidence.ErrorCode,
|
||||||
|
evidence.Summary,
|
||||||
|
evidence.RawText,
|
||||||
|
evidence.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<object> NormalizeConversation(List<ChatMessage>? incoming)
|
||||||
|
{
|
||||||
|
List<object> messages = [];
|
||||||
|
if (incoming is null)
|
||||||
|
return messages;
|
||||||
|
|
||||||
|
foreach (ChatMessage message in incoming)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string role = message.Role.Trim().ToLowerInvariant();
|
||||||
|
if (role is not ("user" or "assistant" or "system"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
messages.Add(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["role"] = role,
|
||||||
|
["content"] = message.Content.Trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractUserRequest(List<ChatMessage>? incoming)
|
||||||
|
{
|
||||||
|
return incoming?
|
||||||
|
.LastOrDefault(message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrWhiteSpace(message.Content))
|
||||||
|
?.Content
|
||||||
|
?.Trim()
|
||||||
|
?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> BuildToolMessage(string callId, string content)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["role"] = "tool",
|
||||||
|
["tool_call_id"] = callId,
|
||||||
|
["content"] = content
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MccStateSummaryData BuildStateSummary(MccRunState runState, MccWebHarnessOptions options)
|
||||||
|
{
|
||||||
|
return new MccStateSummaryData(
|
||||||
|
TurnCount: runState.TurnCount,
|
||||||
|
ToolCallCount: runState.ToolCallCount,
|
||||||
|
SoftFinish: runState.IsSoftFinish(options, DateTimeOffset.UtcNow),
|
||||||
|
DirectAnswerAttempts: runState.DirectAnswerAttempts,
|
||||||
|
OpenVerification: runState.OpenObligations
|
||||||
|
.Select(obligation => new MccVerificationObligationView(obligation.Id, obligation.ToolName, obligation.Kind, obligation.Description))
|
||||||
|
.ToArray(),
|
||||||
|
RecentEvidence: runState.Evidence
|
||||||
|
.TakeLast(6)
|
||||||
|
.Select(evidence => new MccEvidenceView(evidence.Id, evidence.ToolName, evidence.Summary, evidence.IsError))
|
||||||
|
.ToArray(),
|
||||||
|
CompactionSummary: runState.CompactionSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MccBudgetData BuildBudgetData(MccRunState runState)
|
||||||
|
{
|
||||||
|
return new MccBudgetData(
|
||||||
|
TurnCount: runState.TurnCount,
|
||||||
|
MaxTurns: options.MaxTurns,
|
||||||
|
ToolCallCount: runState.ToolCallCount,
|
||||||
|
MaxToolCalls: options.MaxToolCalls,
|
||||||
|
ElapsedSeconds: (DateTimeOffset.UtcNow - runState.StartedAtUtc).TotalSeconds,
|
||||||
|
MaxWallClockSeconds: options.MaxWallClockSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MccEvidenceRecord CreateEvidence(MccRunState runState, string toolName, MccNormalizedToolResult result)
|
||||||
|
{
|
||||||
|
string summary = SummarizeEvidence(toolName, result);
|
||||||
|
return new MccEvidenceRecord
|
||||||
|
{
|
||||||
|
Id = runState.NextEvidenceId(),
|
||||||
|
ToolName = toolName,
|
||||||
|
Summary = summary,
|
||||||
|
RawText = result.Text,
|
||||||
|
IsError = result.IsError,
|
||||||
|
Success = result.Success,
|
||||||
|
ErrorCode = result.ErrorCode,
|
||||||
|
Root = result.Root,
|
||||||
|
Data = result.Data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SummarizeEvidence(string toolName, MccNormalizedToolResult result)
|
||||||
|
{
|
||||||
|
if (result.Data is JsonElement data)
|
||||||
|
{
|
||||||
|
if ((toolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase) || toolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& TryReadBool(data, "arrived", out bool arrived))
|
||||||
|
{
|
||||||
|
return arrived
|
||||||
|
? $"movement verified; arrived={arrived}"
|
||||||
|
: $"movement not yet verified; arrived={arrived}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
bool destroyed = TryReadBool(data, "destroyed", out bool destroyedValue) && destroyedValue;
|
||||||
|
bool changed = TryReadBool(data, "changed", out bool changedValue) && changedValue;
|
||||||
|
return $"dig result changed={changed} destroyed={destroyed}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
int successful = TryReadInt(data, "successfulPickups", out int successfulValue) ? successfulValue : 0;
|
||||||
|
int collected = TryReadInt(data, "collectedCount", out int collectedValue) ? collectedValue : 0;
|
||||||
|
return $"pickup result successfulPickups={successful} collectedCount={collected}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& TryReadBool(data, "opened", out bool opened))
|
||||||
|
{
|
||||||
|
return $"container open result opened={opened}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
|
||||||
|
{
|
||||||
|
int moved = TryReadInt(data, "movedCount", out int movedValue)
|
||||||
|
? movedValue
|
||||||
|
: TryReadInt(data, "droppedCount", out int droppedValue) ? droppedValue : 0;
|
||||||
|
return $"{toolName} movedCount={moved}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string prefix = result.IsError ? "error" : "ok";
|
||||||
|
return $"{prefix}: {Truncate(result.Text.Replace('\n', ' '), 180)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MccVerificationObligation> CreateObligations(MccRunState runState, MccEvidenceRecord evidence, string argumentsJson)
|
||||||
|
{
|
||||||
|
List<MccVerificationObligation> obligations = [];
|
||||||
|
JsonElement metadata = ParseArgumentsToJson(argumentsJson);
|
||||||
|
|
||||||
|
if (evidence.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
MccVerificationObligation obligation = new()
|
||||||
|
{
|
||||||
|
Id = runState.NextObligationId(),
|
||||||
|
ToolName = evidence.ToolName,
|
||||||
|
Kind = "movement",
|
||||||
|
Description = "Verify final player location for the requested move target.",
|
||||||
|
SourceEvidenceId = evidence.Id,
|
||||||
|
Metadata = BuildMoveMetadata(evidence, metadata),
|
||||||
|
Cleared = IsMovementVerified(evidence)
|
||||||
|
};
|
||||||
|
obligations.Add(obligation);
|
||||||
|
return obligations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
MccVerificationObligation obligation = new()
|
||||||
|
{
|
||||||
|
Id = runState.NextObligationId(),
|
||||||
|
ToolName = evidence.ToolName,
|
||||||
|
Kind = "movement",
|
||||||
|
Description = "Verify final proximity to the requested player target.",
|
||||||
|
SourceEvidenceId = evidence.Id,
|
||||||
|
Metadata = BuildMoveToPlayerMetadata(evidence, metadata),
|
||||||
|
Cleared = IsMovementVerified(evidence)
|
||||||
|
};
|
||||||
|
obligations.Add(obligation);
|
||||||
|
return obligations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.ToolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
obligations.Add(new MccVerificationObligation
|
||||||
|
{
|
||||||
|
Id = runState.NextObligationId(),
|
||||||
|
ToolName = evidence.ToolName,
|
||||||
|
Kind = "container",
|
||||||
|
Description = "Verify that the target container is open and active.",
|
||||||
|
SourceEvidenceId = evidence.Id,
|
||||||
|
Metadata = null,
|
||||||
|
Cleared = IsContainerOpenVerified(evidence)
|
||||||
|
});
|
||||||
|
return obligations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.ToolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
|
||||||
|
{
|
||||||
|
obligations.Add(new MccVerificationObligation
|
||||||
|
{
|
||||||
|
Id = runState.NextObligationId(),
|
||||||
|
ToolName = evidence.ToolName,
|
||||||
|
Kind = "inventory",
|
||||||
|
Description = "Verify the requested inventory delta.",
|
||||||
|
SourceEvidenceId = evidence.Id,
|
||||||
|
Metadata = evidence.Data,
|
||||||
|
Cleared = IsInventoryVerified(evidence)
|
||||||
|
});
|
||||||
|
return obligations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.ToolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
obligations.Add(new MccVerificationObligation
|
||||||
|
{
|
||||||
|
Id = runState.NextObligationId(),
|
||||||
|
ToolName = evidence.ToolName,
|
||||||
|
Kind = "pickup",
|
||||||
|
Description = "Verify that the requested dropped items were picked up.",
|
||||||
|
SourceEvidenceId = evidence.Id,
|
||||||
|
Metadata = evidence.Data,
|
||||||
|
Cleared = IsPickupVerified(evidence)
|
||||||
|
});
|
||||||
|
return obligations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.ToolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
obligations.Add(new MccVerificationObligation
|
||||||
|
{
|
||||||
|
Id = runState.NextObligationId(),
|
||||||
|
ToolName = evidence.ToolName,
|
||||||
|
Kind = "block_change",
|
||||||
|
Description = "Verify that the target block changed state after digging.",
|
||||||
|
SourceEvidenceId = evidence.Id,
|
||||||
|
Metadata = evidence.Data,
|
||||||
|
Cleared = IsDigVerified(evidence)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return obligations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MccVerificationObligation> TryClearObligationsFromEvidence(MccRunState runState, MccEvidenceRecord evidence)
|
||||||
|
{
|
||||||
|
List<MccVerificationObligation> cleared = [];
|
||||||
|
foreach (MccVerificationObligation obligation in runState.OpenObligations)
|
||||||
|
{
|
||||||
|
if (obligation.Cleared)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (obligation.Kind == "movement" && TryClearMovementObligation(obligation, evidence))
|
||||||
|
{
|
||||||
|
obligation.Cleared = true;
|
||||||
|
obligation.ClearedByEvidenceId = evidence.Id;
|
||||||
|
cleared.Add(obligation);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obligation.Kind == "block_change" && TryClearDigObligation(obligation, evidence))
|
||||||
|
{
|
||||||
|
obligation.Cleared = true;
|
||||||
|
obligation.ClearedByEvidenceId = evidence.Id;
|
||||||
|
cleared.Add(obligation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryClearMovementObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
|
||||||
|
{
|
||||||
|
if (evidence.ToolName.Equals("mcc_player_state", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& evidence.Data is JsonElement data
|
||||||
|
&& data.TryGetProperty("location", out JsonElement location)
|
||||||
|
&& obligation.Metadata is JsonElement metadata)
|
||||||
|
{
|
||||||
|
if (obligation.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& metadata.TryGetProperty("x", out JsonElement targetX)
|
||||||
|
&& metadata.TryGetProperty("y", out JsonElement targetY)
|
||||||
|
&& metadata.TryGetProperty("z", out JsonElement targetZ))
|
||||||
|
{
|
||||||
|
double tolerance = metadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 1.5;
|
||||||
|
return TryReadDouble(location, "x", out double x)
|
||||||
|
&& TryReadDouble(location, "y", out double y)
|
||||||
|
&& TryReadDouble(location, "z", out double z)
|
||||||
|
&& Distance(x, y, z, targetX.GetDouble(), targetY.GetDouble(), targetZ.GetDouble()) <= tolerance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.ToolName.Equals("mcc_player_locate", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& obligation.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& evidence.Data is JsonElement playerData
|
||||||
|
&& obligation.Metadata is JsonElement playerMetadata)
|
||||||
|
{
|
||||||
|
string? expectedName = playerMetadata.TryGetProperty("playerName", out JsonElement nameElement) ? nameElement.GetString() : null;
|
||||||
|
string? matchedName = playerData.TryGetProperty("matchedName", out JsonElement matchedNameElement) ? matchedNameElement.GetString() : null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(expectedName) && !string.Equals(expectedName, matchedName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (TryReadDouble(playerData, "distance", out double distance))
|
||||||
|
{
|
||||||
|
double tolerance = playerMetadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 2.0;
|
||||||
|
return distance <= tolerance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryClearDigObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
|
||||||
|
{
|
||||||
|
if (!evidence.ToolName.Equals("mcc_world_block_at", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| evidence.Data is not JsonElement data
|
||||||
|
|| obligation.Metadata is not JsonElement metadata)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata.TryGetProperty("target", out JsonElement target)
|
||||||
|
|| !TryReadDouble(target, "x", out double x)
|
||||||
|
|| !TryReadDouble(target, "y", out double y)
|
||||||
|
|| !TryReadDouble(target, "z", out double z))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryReadInt(data, "x", out int blockX)
|
||||||
|
&& TryReadInt(data, "y", out int blockY)
|
||||||
|
&& TryReadInt(data, "z", out int blockZ)
|
||||||
|
&& Math.Abs(blockX - x) < 0.5
|
||||||
|
&& Math.Abs(blockY - y) < 0.5
|
||||||
|
&& Math.Abs(blockZ - z) < 0.5
|
||||||
|
&& data.TryGetProperty("block", out JsonElement block)
|
||||||
|
&& block.TryGetProperty("material", out JsonElement material)
|
||||||
|
&& !string.Equals(material.GetString(), "Air", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMovementVerified(MccEvidenceRecord evidence)
|
||||||
|
{
|
||||||
|
if (evidence.Data is not JsonElement data)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (TryReadBool(data, "arrived", out bool arrived) && arrived)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (TryReadDouble(data, "finalDistance", out double finalDistance))
|
||||||
|
{
|
||||||
|
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
|
||||||
|
return finalDistance <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsContainerOpenVerified(MccEvidenceRecord evidence)
|
||||||
|
{
|
||||||
|
return evidence.Data is JsonElement data
|
||||||
|
&& TryReadBool(data, "opened", out bool opened)
|
||||||
|
&& opened;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInventoryVerified(MccEvidenceRecord evidence)
|
||||||
|
{
|
||||||
|
if (evidence.Data is not JsonElement data)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (TryReadInt(data, "requestedCount", out int requestedCount)
|
||||||
|
&& TryReadInt(data, "movedCount", out int movedCount))
|
||||||
|
{
|
||||||
|
return movedCount == requestedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryReadInt(data, "requestedCount", out requestedCount)
|
||||||
|
&& TryReadInt(data, "droppedCount", out int droppedCount))
|
||||||
|
{
|
||||||
|
return droppedCount == requestedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return evidence.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPickupVerified(MccEvidenceRecord evidence)
|
||||||
|
{
|
||||||
|
if (evidence.Data is not JsonElement data)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return (TryReadInt(data, "successfulPickups", out int successfulPickups) && successfulPickups > 0)
|
||||||
|
|| (TryReadInt(data, "collectedCount", out int collectedCount) && collectedCount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDigVerified(MccEvidenceRecord evidence)
|
||||||
|
{
|
||||||
|
if (evidence.Data is not JsonElement data)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return (TryReadBool(data, "destroyed", out bool destroyed) && destroyed)
|
||||||
|
|| (TryReadBool(data, "changed", out bool changed) && changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? BuildMoveMetadata(MccEvidenceRecord evidence, JsonElement arguments)
|
||||||
|
{
|
||||||
|
if (evidence.Data is not JsonElement data)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
double x = TryReadDoubleFromArguments(arguments, "x", out double targetX)
|
||||||
|
? targetX
|
||||||
|
: data.TryGetProperty("target", out JsonElement target) && TryReadDouble(target, "x", out double fromDataX) ? fromDataX : 0;
|
||||||
|
double y = TryReadDoubleFromArguments(arguments, "y", out double targetY)
|
||||||
|
? targetY
|
||||||
|
: data.TryGetProperty("target", out target) && TryReadDouble(target, "y", out double fromDataY) ? fromDataY : 0;
|
||||||
|
double z = TryReadDoubleFromArguments(arguments, "z", out double targetZ)
|
||||||
|
? targetZ
|
||||||
|
: data.TryGetProperty("target", out target) && TryReadDouble(target, "z", out double fromDataZ) ? fromDataZ : 0;
|
||||||
|
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
|
||||||
|
|
||||||
|
return JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
tolerance
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? BuildMoveToPlayerMetadata(MccEvidenceRecord evidence, JsonElement arguments)
|
||||||
|
{
|
||||||
|
string? playerName = arguments.TryGetProperty("playerName", out JsonElement property) ? property.GetString() : null;
|
||||||
|
double tolerance = evidence.Data is JsonElement data && TryReadDouble(data, "tolerance", out double tol) ? tol : 2.0;
|
||||||
|
return JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
playerName,
|
||||||
|
tolerance
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement ParseArgumentsToJson(string argumentsJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse("{}");
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadBool(JsonElement element, string propertyName, out bool value)
|
||||||
|
{
|
||||||
|
value = false;
|
||||||
|
return element.TryGetProperty(propertyName, out JsonElement property)
|
||||||
|
&& property.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||||
|
&& ((value = property.GetBoolean()) || !value || true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadInt(JsonElement element, string propertyName, out int value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetInt32(out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadDouble(JsonElement element, string propertyName, out double value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetDouble(out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadDoubleFromArguments(JsonElement element, string propertyName, out double value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
if (!element.TryGetProperty(propertyName, out JsonElement property))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return property.ValueKind == JsonValueKind.Number
|
||||||
|
? property.TryGetDouble(out value)
|
||||||
|
: property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString(), out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Distance(double x1, double y1, double z1, double x2, double y2, double z2)
|
||||||
|
{
|
||||||
|
double dx = x1 - x2;
|
||||||
|
double dy = y1 - y2;
|
||||||
|
double dz = z1 - z2;
|
||||||
|
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string text, int maxLength)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(text) || text.Length <= maxLength ? text : text[..maxLength] + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SseItem<MccStreamEnvelope> CreateEvent<T>(string runId, ref long sequence, string kind, T data)
|
||||||
|
{
|
||||||
|
sequence++;
|
||||||
|
return new SseItem<MccStreamEnvelope>(
|
||||||
|
new MccStreamEnvelope(runId, sequence, kind, data!),
|
||||||
|
kind)
|
||||||
|
{
|
||||||
|
EventId = sequence.ToString(CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
public sealed class MccContextCompressor
|
||||||
|
{
|
||||||
|
public void CompactIfNeeded(MccRunState runState)
|
||||||
|
{
|
||||||
|
if (runState.Evidence.Count <= 6)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IReadOnlyList<MccEvidenceRecord> olderEvidence = runState.Evidence
|
||||||
|
.Take(Math.Max(0, runState.Evidence.Count - 6))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (olderEvidence.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
runState.CompactionSummary = string.Join('\n', olderEvidence
|
||||||
|
.TakeLast(8)
|
||||||
|
.Select(record => $"- {record.Id} {record.ToolName}: {record.Summary}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
221
DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs
Normal file
221
DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
public sealed class MccFinalizer
|
||||||
|
{
|
||||||
|
private static readonly string[] AllowedStatuses = ["completed", "partial", "blocked", "clarification_needed", "failed"];
|
||||||
|
|
||||||
|
public object BuildSubmitToolSchema()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["type"] = "function",
|
||||||
|
["function"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["name"] = "mcc_submit_final",
|
||||||
|
["description"] = "Submit the final result for this MCC run. Use completed only when no required verification obligations remain open.",
|
||||||
|
["parameters"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "object",
|
||||||
|
["additionalProperties"] = false,
|
||||||
|
["properties"] = new JsonObject
|
||||||
|
{
|
||||||
|
["status"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "string",
|
||||||
|
["enum"] = new JsonArray(AllowedStatuses.Select(status => JsonValue.Create(status)).ToArray())
|
||||||
|
},
|
||||||
|
["headline"] = new JsonObject { ["type"] = "string" },
|
||||||
|
["answerMarkdown"] = new JsonObject { ["type"] = "string" },
|
||||||
|
["verifiedFacts"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "array",
|
||||||
|
["items"] = new JsonObject { ["type"] = "string" }
|
||||||
|
},
|
||||||
|
["openIssues"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "array",
|
||||||
|
["items"] = new JsonObject { ["type"] = "string" }
|
||||||
|
},
|
||||||
|
["evidenceIds"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "array",
|
||||||
|
["items"] = new JsonObject { ["type"] = "string" }
|
||||||
|
},
|
||||||
|
["nextAction"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = new JsonArray("string", "null")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
["required"] = new JsonArray("status", "headline", "answerMarkdown", "verifiedFacts", "openIssues", "evidenceIds", "nextAction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public MccFinalizationValidation Validate(MccRunState runState, string argumentsJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||||||
|
JsonElement root = document.RootElement;
|
||||||
|
MccSubmitFinalArgs submission = new(
|
||||||
|
Status: ReadRequiredString(root, "status"),
|
||||||
|
Headline: ReadRequiredString(root, "headline"),
|
||||||
|
AnswerMarkdown: ReadRequiredString(root, "answerMarkdown"),
|
||||||
|
VerifiedFacts: ReadStringArray(root, "verifiedFacts"),
|
||||||
|
OpenIssues: ReadStringArray(root, "openIssues"),
|
||||||
|
EvidenceIds: ReadStringArray(root, "evidenceIds"),
|
||||||
|
NextAction: ReadNullableString(root, "nextAction"));
|
||||||
|
|
||||||
|
string normalizedStatus = submission.Status.Trim().ToLowerInvariant();
|
||||||
|
if (!AllowedStatuses.Contains(normalizedStatus, StringComparer.Ordinal))
|
||||||
|
return MccFinalizationValidation.Reject("Invalid final status.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(submission.Headline) || string.IsNullOrWhiteSpace(submission.AnswerMarkdown))
|
||||||
|
return MccFinalizationValidation.Reject("headline and answerMarkdown are required.");
|
||||||
|
|
||||||
|
Dictionary<string, MccEvidenceRecord> evidenceById = runState.Evidence.ToDictionary(record => record.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
Dictionary<string, string> evidenceAliasByCallId = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (MccToolExecutionRecord execution in runState.ToolExecutions)
|
||||||
|
{
|
||||||
|
evidenceAliasByCallId[execution.CallId] = execution.Evidence.Id;
|
||||||
|
|
||||||
|
int suffixSeparator = execution.CallId.LastIndexOf('_');
|
||||||
|
if (suffixSeparator >= 0 && suffixSeparator < execution.CallId.Length - 1)
|
||||||
|
evidenceAliasByCallId[execution.CallId[(suffixSeparator + 1)..]] = execution.Evidence.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> normalizedEvidenceIds = [];
|
||||||
|
foreach (string evidenceId in submission.EvidenceIds)
|
||||||
|
{
|
||||||
|
string normalizedEvidenceId = evidenceAliasByCallId.TryGetValue(evidenceId, out string? mappedEvidenceId)
|
||||||
|
? mappedEvidenceId
|
||||||
|
: evidenceId;
|
||||||
|
|
||||||
|
if (!evidenceById.ContainsKey(normalizedEvidenceId))
|
||||||
|
return MccFinalizationValidation.Reject($"Unknown evidence id '{evidenceId}'.");
|
||||||
|
|
||||||
|
if (!normalizedEvidenceIds.Contains(normalizedEvidenceId, StringComparer.OrdinalIgnoreCase))
|
||||||
|
normalizedEvidenceIds.Add(normalizedEvidenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedStatus == "completed" && runState.OpenObligations.Count > 0)
|
||||||
|
return MccFinalizationValidation.Reject("completed is invalid while verification obligations remain open.");
|
||||||
|
|
||||||
|
if (!AreVerifiedFactsGrounded(submission.VerifiedFacts, normalizedEvidenceIds, evidenceById))
|
||||||
|
return MccFinalizationValidation.Reject("verifiedFacts must be grounded in the referenced evidence.");
|
||||||
|
|
||||||
|
return MccFinalizationValidation.Accept(new MccFinalPayload(
|
||||||
|
normalizedStatus,
|
||||||
|
submission.Headline.Trim(),
|
||||||
|
submission.AnswerMarkdown.Trim(),
|
||||||
|
submission.VerifiedFacts,
|
||||||
|
submission.OpenIssues,
|
||||||
|
normalizedEvidenceIds,
|
||||||
|
string.IsNullOrWhiteSpace(submission.NextAction) ? null : submission.NextAction.Trim()));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return MccFinalizationValidation.Reject($"Invalid mcc_submit_final payload: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MccFinalPayload BuildHardStopResult(MccRunState runState, MccWebHarnessOptions options)
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> openIssues = runState.OpenObligations.Count > 0
|
||||||
|
? runState.OpenObligations.Select(obligation => obligation.Description).ToArray()
|
||||||
|
: ["The harness reached its execution budget before the run was explicitly finalized."];
|
||||||
|
|
||||||
|
IReadOnlyList<string> evidenceIds = runState.Evidence.TakeLast(4).Select(record => record.Id).ToArray();
|
||||||
|
IReadOnlyList<string> verifiedFacts = runState.Evidence
|
||||||
|
.TakeLast(4)
|
||||||
|
.Where(record => record.Success)
|
||||||
|
.Select(record => record.Summary)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new MccFinalPayload(
|
||||||
|
Status: runState.OpenObligations.Count > 0 ? "partial" : "blocked",
|
||||||
|
Headline: "Run stopped before explicit completion",
|
||||||
|
AnswerMarkdown: "I could not finish the request within the current harness budget. I am returning the strongest verified state captured so far.",
|
||||||
|
VerifiedFacts: verifiedFacts,
|
||||||
|
OpenIssues: openIssues,
|
||||||
|
EvidenceIds: evidenceIds,
|
||||||
|
NextAction: "Retry with a fresh run if you want me to continue from the latest verified state.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool AreVerifiedFactsGrounded(
|
||||||
|
IReadOnlyList<string> verifiedFacts,
|
||||||
|
IReadOnlyList<string> evidenceIds,
|
||||||
|
IReadOnlyDictionary<string, MccEvidenceRecord> evidenceById)
|
||||||
|
{
|
||||||
|
if (verifiedFacts.Count == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (evidenceIds.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string evidenceCorpus = string.Join(' ', evidenceIds
|
||||||
|
.Where(evidenceById.ContainsKey)
|
||||||
|
.Select(id => evidenceById[id].Summary))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
|
||||||
|
foreach (string fact in verifiedFacts)
|
||||||
|
{
|
||||||
|
HashSet<string> factTokens = fact.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(token => token.Trim().Trim(',', '.', ':', ';', '!', '?', '"', '\''))
|
||||||
|
.Where(token => token.Length >= 4)
|
||||||
|
.Select(token => token.ToLowerInvariant())
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (factTokens.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int matches = factTokens.Count(token => evidenceCorpus.Contains(token, StringComparison.Ordinal));
|
||||||
|
if (matches < Math.Min(2, factTokens.Count))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadRequiredString(JsonElement root, string propertyName)
|
||||||
|
{
|
||||||
|
string? value = ReadNullableString(root, propertyName);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
throw new InvalidOperationException($"{propertyName} is required.");
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadNullableString(JsonElement root, string propertyName)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty(propertyName, out JsonElement property))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return property.ValueKind == JsonValueKind.Null ? null : property.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ReadStringArray(JsonElement root, string propertyName)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty(propertyName, out JsonElement property) || property.ValueKind != JsonValueKind.Array)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return property.EnumerateArray()
|
||||||
|
.Where(item => item.ValueKind == JsonValueKind.String)
|
||||||
|
.Select(item => item.GetString())
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MccFinalizationValidation(bool Accepted, string? ErrorText, MccFinalPayload? Payload)
|
||||||
|
{
|
||||||
|
public static MccFinalizationValidation Accept(MccFinalPayload payload) => new(true, null, payload);
|
||||||
|
|
||||||
|
public static MccFinalizationValidation Reject(string errorText) => new(false, errorText, null);
|
||||||
|
}
|
||||||
63
DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs
Normal file
63
DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
public sealed class MccGuidanceSource
|
||||||
|
{
|
||||||
|
public const string SourceToolName = "mcc_agent_guidance";
|
||||||
|
public const string CanonicalPromptName = "mcc_operator_guide";
|
||||||
|
|
||||||
|
public async Task<MccGuidanceBundle> LoadAsync(McpClient client, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CallToolResult result = await client.CallToolAsync(SourceToolName, new Dictionary<string, object?>(), cancellationToken: cancellationToken);
|
||||||
|
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
|
||||||
|
JsonElement data = normalized.Data ?? throw new InvalidOperationException("mcc_agent_guidance did not return data.");
|
||||||
|
|
||||||
|
string[] bestPractices = ReadStringArray(data, "bestPractices");
|
||||||
|
string[] exampleTitles = data.TryGetProperty("exampleScenarios", out JsonElement examples)
|
||||||
|
&& examples.ValueKind == JsonValueKind.Array
|
||||||
|
? examples.EnumerateArray()
|
||||||
|
.Select(example => example.TryGetProperty("title", out JsonElement title) ? title.GetString() : null)
|
||||||
|
.Where(title => !string.IsNullOrWhiteSpace(title))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToArray()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
MccCapabilityStatus capabilityStatus = data.TryGetProperty("capabilityStatus", out JsonElement capabilityJson)
|
||||||
|
? JsonSerializer.Deserialize<MccCapabilityStatus>(capabilityJson.GetRawText()) ?? new MccCapabilityStatus(false, false, false, false, false)
|
||||||
|
: new MccCapabilityStatus(false, false, false, false, false);
|
||||||
|
|
||||||
|
return new MccGuidanceBundle(
|
||||||
|
SourceToolName,
|
||||||
|
CanonicalPromptName,
|
||||||
|
SkillName: ReadString(data, "skillName") ?? "mcc-mcp-operator",
|
||||||
|
GuidanceVersion: ReadString(data, "guidanceVersion") ?? "unknown",
|
||||||
|
SystemPrompt: ReadString(data, "systemPrompt") ?? throw new InvalidOperationException("mcc_agent_guidance did not return systemPrompt."),
|
||||||
|
BestPractices: bestPractices,
|
||||||
|
ExampleScenarioTitles: exampleTitles,
|
||||||
|
CapabilityStatus: capabilityStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadString(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
|
||||||
|
? property.GetString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ReadStringArray(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.Array
|
||||||
|
? property.EnumerateArray()
|
||||||
|
.Where(item => item.ValueKind == JsonValueKind.String)
|
||||||
|
.Select(item => item.GetString())
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToArray()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs
Normal file
86
DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
public sealed class MccPromptComposer
|
||||||
|
{
|
||||||
|
private const string HarnessContract = """
|
||||||
|
You are operating Minecraft Console Client through MCC MCP tools.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use tool results and the run-state summary as the source of truth.
|
||||||
|
- Execute tools sequentially.
|
||||||
|
- End the run only with mcc_submit_final.
|
||||||
|
- status=completed is valid only when no required verification obligations remain open.
|
||||||
|
- If the task is blocked or partial, say exactly what is verified and what remains unverified.
|
||||||
|
- Do not repeat the same failing stateful action with the same arguments.
|
||||||
|
- mcc_quit_client requires explicit user intent.
|
||||||
|
- Prefer structured high-level tools. Avoid escape hatches unless they are explicitly exposed and necessary.
|
||||||
|
""";
|
||||||
|
|
||||||
|
public List<object> Compose(MccRunState runState)
|
||||||
|
{
|
||||||
|
List<object> messages =
|
||||||
|
[
|
||||||
|
BuildSystemMessage(HarnessContract),
|
||||||
|
BuildSystemMessage(runState.Guidance.SystemPrompt),
|
||||||
|
BuildSystemMessage(BuildStateSummary(runState)),
|
||||||
|
.. runState.BaseConversationMessages
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(runState.CompactionSummary))
|
||||||
|
{
|
||||||
|
messages.Add(BuildSystemMessage($"""
|
||||||
|
Older verified evidence summary
|
||||||
|
{runState.CompactionSummary}
|
||||||
|
"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (object message in runState.ToolConversationMessages.TakeLast(12))
|
||||||
|
messages.Add(message);
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> BuildSystemMessage(string text)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["role"] = "system",
|
||||||
|
["content"] = text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildStateSummary(MccRunState runState)
|
||||||
|
{
|
||||||
|
string evidence = runState.Evidence.Count == 0
|
||||||
|
? "- none yet"
|
||||||
|
: string.Join('\n', runState.Evidence.TakeLast(6).Select(record =>
|
||||||
|
$"- {record.Id} {record.ToolName}: {record.Summary}"));
|
||||||
|
|
||||||
|
string obligations = runState.OpenObligations.Count == 0
|
||||||
|
? "- none"
|
||||||
|
: string.Join('\n', runState.OpenObligations.Select(obligation =>
|
||||||
|
$"- {obligation.Id} {obligation.ToolName}/{obligation.Kind}: {obligation.Description}"));
|
||||||
|
|
||||||
|
string bestPractices = runState.Guidance.BestPractices.Length == 0
|
||||||
|
? "- use verified MCC state before claiming success"
|
||||||
|
: string.Join('\n', runState.Guidance.BestPractices.Take(4).Select(item => $"- {item}"));
|
||||||
|
|
||||||
|
return $"""
|
||||||
|
Current run state
|
||||||
|
- turnCount: {runState.TurnCount}
|
||||||
|
- toolCallCount: {runState.ToolCallCount}
|
||||||
|
- directAnswerAttempts: {runState.DirectAnswerAttempts}
|
||||||
|
- routedModel: {runState.RoutedModel ?? runState.ConfiguredModel}
|
||||||
|
- routedProvider: {runState.RoutedProvider ?? "unknown"}
|
||||||
|
|
||||||
|
Outstanding verification
|
||||||
|
{obligations}
|
||||||
|
|
||||||
|
Recent evidence
|
||||||
|
{evidence}
|
||||||
|
|
||||||
|
Guidance highlights
|
||||||
|
{bestPractices}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
103
DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs
Normal file
103
DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
public sealed class MccRunState
|
||||||
|
{
|
||||||
|
private int evidenceCounter;
|
||||||
|
private int obligationCounter;
|
||||||
|
|
||||||
|
public required string RunId { get; init; }
|
||||||
|
public required string UserRequest { get; init; }
|
||||||
|
public required List<object> BaseConversationMessages { get; init; }
|
||||||
|
public required string ConfiguredModel { get; init; }
|
||||||
|
public required MccGuidanceBundle Guidance { get; init; }
|
||||||
|
public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public List<object> ToolConversationMessages { get; } = [];
|
||||||
|
public List<MccEvidenceRecord> Evidence { get; } = [];
|
||||||
|
public List<MccToolExecutionRecord> ToolExecutions { get; } = [];
|
||||||
|
public List<MccVerificationObligation> VerificationObligations { get; } = [];
|
||||||
|
public string? CompactionSummary { get; set; }
|
||||||
|
public string? RoutedModel { get; set; }
|
||||||
|
public string? RoutedProvider { get; set; }
|
||||||
|
public int TurnCount { get; set; }
|
||||||
|
public int ToolCallCount { get; set; }
|
||||||
|
public int DirectAnswerAttempts { get; set; }
|
||||||
|
|
||||||
|
public string NextEvidenceId() => $"e{++evidenceCounter:0000}";
|
||||||
|
|
||||||
|
public string NextObligationId() => $"v{++obligationCounter:0000}";
|
||||||
|
|
||||||
|
public bool IsSoftFinish(MccWebHarnessOptions options, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
TimeSpan elapsed = nowUtc - StartedAtUtc;
|
||||||
|
return (options.MaxTurns - TurnCount) <= options.SoftFinishRemainingTurns
|
||||||
|
|| (options.MaxToolCalls - ToolCallCount) <= options.SoftFinishRemainingToolCalls
|
||||||
|
|| (options.MaxWallClockSeconds - (int)elapsed.TotalSeconds) <= options.SoftFinishRemainingSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsHardStop(MccWebHarnessOptions options, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
TimeSpan elapsed = nowUtc - StartedAtUtc;
|
||||||
|
return TurnCount >= options.MaxTurns
|
||||||
|
|| ToolCallCount >= options.MaxToolCalls
|
||||||
|
|| elapsed.TotalSeconds >= options.MaxWallClockSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<MccVerificationObligation> OpenObligations =>
|
||||||
|
VerificationObligations.Where(obligation => !obligation.Cleared).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MccGuidanceBundle(
|
||||||
|
string SourceToolName,
|
||||||
|
string CanonicalPromptName,
|
||||||
|
string SkillName,
|
||||||
|
string GuidanceVersion,
|
||||||
|
string SystemPrompt,
|
||||||
|
string[] BestPractices,
|
||||||
|
string[] ExampleScenarioTitles,
|
||||||
|
MccCapabilityStatus CapabilityStatus);
|
||||||
|
|
||||||
|
public sealed class MccEvidenceRecord
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string ToolName { get; init; }
|
||||||
|
public required string Summary { get; init; }
|
||||||
|
public required string RawText { get; init; }
|
||||||
|
public required bool IsError { get; init; }
|
||||||
|
public required bool Success { get; init; }
|
||||||
|
public string? ErrorCode { get; init; }
|
||||||
|
public JsonElement? Root { get; init; }
|
||||||
|
public JsonElement? Data { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MccToolExecutionRecord
|
||||||
|
{
|
||||||
|
public required string CallId { get; init; }
|
||||||
|
public required string ToolName { get; init; }
|
||||||
|
public required string ArgumentsJson { get; init; }
|
||||||
|
public required MccEvidenceRecord Evidence { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MccVerificationObligation
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string ToolName { get; init; }
|
||||||
|
public required string Kind { get; init; }
|
||||||
|
public required string Description { get; init; }
|
||||||
|
public required string SourceEvidenceId { get; init; }
|
||||||
|
public JsonElement? Metadata { get; init; }
|
||||||
|
public bool Cleared { get; set; }
|
||||||
|
public string? ClearedByEvidenceId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MccNormalizedToolResult(
|
||||||
|
string Text,
|
||||||
|
bool IsError,
|
||||||
|
bool Success,
|
||||||
|
string? ErrorCode,
|
||||||
|
string? Message,
|
||||||
|
JsonElement? Root,
|
||||||
|
JsonElement? Data);
|
||||||
133
DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs
Normal file
133
DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
public enum MccToolRisk
|
||||||
|
{
|
||||||
|
ReadOnly,
|
||||||
|
Stateful,
|
||||||
|
Sensitive,
|
||||||
|
EscapeHatch
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MccToolProfile(
|
||||||
|
string Name,
|
||||||
|
MccToolRisk Risk,
|
||||||
|
bool VisibleByDefault,
|
||||||
|
bool RequiresExplicitUserIntent);
|
||||||
|
|
||||||
|
public sealed record MccToolCatalogEntry(McpClientTool Tool, MccToolProfile Profile);
|
||||||
|
|
||||||
|
public sealed class MccToolCatalog
|
||||||
|
{
|
||||||
|
public required Dictionary<string, MccToolCatalogEntry> ToolsByName { get; init; }
|
||||||
|
public required IReadOnlyList<object> ModelVisibleTools { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MccToolPolicy
|
||||||
|
{
|
||||||
|
private static readonly FrozenDictionary<string, MccToolProfile> Profiles =
|
||||||
|
new Dictionary<string, MccToolProfile>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["mcc_agent_guidance"] = new("mcc_agent_guidance", MccToolRisk.ReadOnly, false, false),
|
||||||
|
["mcc_inventory_window_action"] = new("mcc_inventory_window_action", MccToolRisk.EscapeHatch, false, false),
|
||||||
|
["mcc_run_internal_command"] = new("mcc_run_internal_command", MccToolRisk.EscapeHatch, false, false),
|
||||||
|
["mcc_quit_client"] = new("mcc_quit_client", MccToolRisk.Sensitive, true, true)
|
||||||
|
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static MccToolProfile GetProfile(string toolName)
|
||||||
|
{
|
||||||
|
return Profiles.TryGetValue(toolName, out MccToolProfile? profile)
|
||||||
|
? profile
|
||||||
|
: new MccToolProfile(toolName, MccToolRisk.Stateful, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MccToolCatalog BuildCatalog(IList<McpClientTool> tools, MccWebHarnessOptions options, object submitFinalTool)
|
||||||
|
{
|
||||||
|
Dictionary<string, MccToolCatalogEntry> toolsByName = tools.ToDictionary(
|
||||||
|
tool => tool.Name,
|
||||||
|
tool => new MccToolCatalogEntry(tool, GetProfile(tool.Name)),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
List<object> visibleTools = [];
|
||||||
|
foreach (MccToolCatalogEntry entry in toolsByName.Values.OrderBy(entry => entry.Tool.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!IsVisible(entry.Profile, options))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
visibleTools.Add(ToOpenRouterTool(entry.Tool, entry.Profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleTools.Add(submitFinalTool);
|
||||||
|
|
||||||
|
return new MccToolCatalog
|
||||||
|
{
|
||||||
|
ToolsByName = toolsByName,
|
||||||
|
ModelVisibleTools = visibleTools
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool RequiresExplicitUserIntent(string toolName)
|
||||||
|
{
|
||||||
|
return GetProfile(toolName).RequiresExplicitUserIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasExplicitUserIntent(string userRequest, string toolName)
|
||||||
|
{
|
||||||
|
if (!RequiresExplicitUserIntent(toolName))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
string request = userRequest.Trim().ToLowerInvariant();
|
||||||
|
return toolName.Equals("mcc_quit_client", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (request.Contains("quit mcc", StringComparison.Ordinal)
|
||||||
|
|| request.Contains("close mcc", StringComparison.Ordinal)
|
||||||
|
|| request.Contains("stop mcc", StringComparison.Ordinal)
|
||||||
|
|| request.Contains("exit mcc", StringComparison.Ordinal)
|
||||||
|
|| request.Contains("quit the client", StringComparison.Ordinal)
|
||||||
|
|| request.Contains("stop the client", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsVisible(MccToolProfile profile, MccWebHarnessOptions options)
|
||||||
|
{
|
||||||
|
if (!profile.VisibleByDefault)
|
||||||
|
{
|
||||||
|
if (profile.Name.Equals("mcc_inventory_window_action", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return options.ExposeInventoryWindowAction;
|
||||||
|
|
||||||
|
if (profile.Name.Equals("mcc_run_internal_command", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return options.ExposeInternalCommandTool;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ToOpenRouterTool(McpClientTool tool, MccToolProfile profile)
|
||||||
|
{
|
||||||
|
JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "object",
|
||||||
|
["properties"] = new JsonObject()
|
||||||
|
};
|
||||||
|
|
||||||
|
string description = tool.Description ?? string.Empty;
|
||||||
|
if (profile.Risk == MccToolRisk.Sensitive)
|
||||||
|
description = $"{description} Requires explicit user intent.";
|
||||||
|
else if (profile.Risk == MccToolRisk.EscapeHatch)
|
||||||
|
description = $"{description} Advanced escape hatch; prefer higher-level tools first.";
|
||||||
|
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["type"] = "function",
|
||||||
|
["function"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["name"] = tool.Name,
|
||||||
|
["description"] = description,
|
||||||
|
["parameters"] = parameters
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
public sealed class MccWebHarnessOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "MccWebHarness";
|
||||||
|
|
||||||
|
public string? Model { get; set; }
|
||||||
|
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1";
|
||||||
|
public string McpEndpoint { get; set; } = "http://127.0.0.1:33333/mcp";
|
||||||
|
public int MaxTurns { get; set; } = 48;
|
||||||
|
public int MaxToolCalls { get; set; } = 120;
|
||||||
|
public int MaxWallClockSeconds { get; set; } = 240;
|
||||||
|
public int SoftFinishRemainingTurns { get; set; } = 3;
|
||||||
|
public int SoftFinishRemainingToolCalls { get; set; } = 8;
|
||||||
|
public int SoftFinishRemainingSeconds { get; set; } = 30;
|
||||||
|
public bool RequireProviderParameters { get; set; } = true;
|
||||||
|
public bool AllowFallbacks { get; set; }
|
||||||
|
public bool DisableParallelToolCalls { get; set; } = true;
|
||||||
|
public bool ExposeInventoryWindowAction { get; set; }
|
||||||
|
public bool ExposeInternalCommandTool { get; set; }
|
||||||
|
|
||||||
|
public string? ResolveModel()
|
||||||
|
{
|
||||||
|
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_MODEL"), Model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ResolveOpenRouterBaseUrl()
|
||||||
|
{
|
||||||
|
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL"), OpenRouterBaseUrl)
|
||||||
|
?? "https://openrouter.ai/api/v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ResolveMcpEndpoint()
|
||||||
|
{
|
||||||
|
return FirstNonEmpty(Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT"), McpEndpoint)
|
||||||
|
?? "http://127.0.0.1:33333/mcp";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ResolveMcpAuthToken()
|
||||||
|
{
|
||||||
|
return Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ResolveApiKey()
|
||||||
|
{
|
||||||
|
return Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasApiKeyConfigured()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(ResolveApiKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FirstNonEmpty(params string?[] candidates)
|
||||||
|
{
|
||||||
|
return candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate))?.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Reflection;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
|
||||||
|
|
||||||
|
public sealed class MccMcpSessionFactory
|
||||||
|
{
|
||||||
|
private readonly MccWebHarnessOptions options;
|
||||||
|
|
||||||
|
public MccMcpSessionFactory(IOptions<MccWebHarnessOptions> options)
|
||||||
|
{
|
||||||
|
this.options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<McpClient> CreateAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string endpoint = options.ResolveMcpEndpoint();
|
||||||
|
string? token = options.ResolveMcpAuthToken();
|
||||||
|
|
||||||
|
return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri(endpoint),
|
||||||
|
TransportMode = HttpTransportMode.AutoDetect,
|
||||||
|
AdditionalHeaders = string.IsNullOrWhiteSpace(token)
|
||||||
|
? null
|
||||||
|
: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Authorization"] = $"Bearer {token}"
|
||||||
|
}
|
||||||
|
}), cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MccMcpJson
|
||||||
|
{
|
||||||
|
public static MccNormalizedToolResult Normalize(CallToolResult result)
|
||||||
|
{
|
||||||
|
JsonElement? structuredRoot = TryReadStructuredContent(result);
|
||||||
|
string text = ReadToolResultText(result, structuredRoot);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(text);
|
||||||
|
JsonElement parsedRoot = document.RootElement.Clone();
|
||||||
|
JsonElement root = ShouldPreferStructuredRoot(parsedRoot, structuredRoot)
|
||||||
|
? structuredRoot!.Value
|
||||||
|
: parsedRoot;
|
||||||
|
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement)
|
||||||
|
? dataElement.Clone()
|
||||||
|
: ShouldTreatRootAsData(root) ? root.Clone() : structuredRoot;
|
||||||
|
bool success = root.TryGetProperty("success", out JsonElement successElement)
|
||||||
|
? successElement.ValueKind != JsonValueKind.False
|
||||||
|
: result.IsError != true;
|
||||||
|
string? errorCode = root.TryGetProperty("errorCode", out JsonElement errorCodeElement) && errorCodeElement.ValueKind == JsonValueKind.String
|
||||||
|
? errorCodeElement.GetString()
|
||||||
|
: null;
|
||||||
|
string? message = root.TryGetProperty("message", out JsonElement messageElement) && messageElement.ValueKind == JsonValueKind.String
|
||||||
|
? messageElement.GetString()
|
||||||
|
: null;
|
||||||
|
bool isError = result.IsError == true || !success || !string.IsNullOrWhiteSpace(errorCode);
|
||||||
|
|
||||||
|
return new MccNormalizedToolResult(text, isError, success, errorCode, message, root, data);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
bool isError = result.IsError == true;
|
||||||
|
return new MccNormalizedToolResult(text, isError, !isError, null, null, structuredRoot, structuredRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadToolResultText(CallToolResult result, JsonElement? structuredRoot)
|
||||||
|
{
|
||||||
|
if (result.Content is null)
|
||||||
|
return structuredRoot?.GetRawText() ?? (result.IsError == true ? "{\"success\":false}" : "{\"success\":true}");
|
||||||
|
|
||||||
|
StringBuilder builder = new();
|
||||||
|
foreach (ContentBlock block in result.Content)
|
||||||
|
{
|
||||||
|
if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
|
||||||
|
{
|
||||||
|
if (builder.Length > 0)
|
||||||
|
builder.Append('\n');
|
||||||
|
builder.Append(text.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Length > 0
|
||||||
|
? builder.ToString()
|
||||||
|
: structuredRoot?.GetRawText()
|
||||||
|
?? JsonSerializer.Serialize(new { success = result.IsError != true, isError = result.IsError });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? TryReadStructuredContent(CallToolResult result)
|
||||||
|
{
|
||||||
|
PropertyInfo? property = typeof(CallToolResult).GetProperty("StructuredContent", BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
if (property?.GetValue(result) is not { } value)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
JsonElement json when json.ValueKind != JsonValueKind.Undefined && json.ValueKind != JsonValueKind.Null => json.Clone(),
|
||||||
|
JsonDocument document => document.RootElement.Clone(),
|
||||||
|
string text when !string.IsNullOrWhiteSpace(text) => TryParseJson(text),
|
||||||
|
_ => TrySerializeToJson(value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? TrySerializeToJson(object value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.SerializeToElement(value);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? TryParseJson(string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(text);
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldPreferStructuredRoot(JsonElement parsedRoot, JsonElement? structuredRoot)
|
||||||
|
{
|
||||||
|
if (structuredRoot is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (parsedRoot.ValueKind != JsonValueKind.Object)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return !parsedRoot.EnumerateObject().Any(property =>
|
||||||
|
!property.NameEquals("success") &&
|
||||||
|
!property.NameEquals("isError"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldTreatRootAsData(JsonElement root)
|
||||||
|
{
|
||||||
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return root.EnumerateObject().Any(property =>
|
||||||
|
!property.NameEquals("success") &&
|
||||||
|
!property.NameEquals("isError") &&
|
||||||
|
!property.NameEquals("errorCode") &&
|
||||||
|
!property.NameEquals("message"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MccJsonArguments
|
||||||
|
{
|
||||||
|
public static Dictionary<string, object?> Parse(string rawJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(rawJson) ? "{}" : rawJson);
|
||||||
|
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||||
|
return new Dictionary<string, object?>();
|
||||||
|
|
||||||
|
Dictionary<string, object?> values = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (JsonProperty property in document.RootElement.EnumerateObject())
|
||||||
|
values[property.Name] = Convert(property.Value);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? Convert(JsonElement element)
|
||||||
|
{
|
||||||
|
return element.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Null => null,
|
||||||
|
JsonValueKind.True => true,
|
||||||
|
JsonValueKind.False => false,
|
||||||
|
JsonValueKind.Number => element.TryGetInt64(out long i64)
|
||||||
|
? i64
|
||||||
|
: element.TryGetDouble(out double d) ? d : element.GetRawText(),
|
||||||
|
JsonValueKind.String => element.GetString(),
|
||||||
|
JsonValueKind.Array => element.EnumerateArray().Select(Convert).ToArray(),
|
||||||
|
JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => Convert(property.Value)),
|
||||||
|
_ => element.GetRawText()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DebugTools.MccMcpWebPlayground.Harness;
|
||||||
|
|
||||||
|
namespace DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
|
||||||
|
|
||||||
|
public sealed class OpenRouterChatClient
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory httpClientFactory;
|
||||||
|
|
||||||
|
public OpenRouterChatClient(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
this.httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MccModelTurn> CreateTurnAsync(
|
||||||
|
List<object> messages,
|
||||||
|
IReadOnlyList<object> tools,
|
||||||
|
MccWebHarnessOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string apiKey = options.ResolveApiKey() ?? throw new InvalidOperationException("OPENROUTER_API_KEY is not configured.");
|
||||||
|
string model = options.ResolveModel() ?? throw new InvalidOperationException("Model is not configured.");
|
||||||
|
|
||||||
|
using HttpClient client = httpClientFactory.CreateClient("openrouter");
|
||||||
|
client.BaseAddress = new Uri(options.ResolveOpenRouterBaseUrl().TrimEnd('/') + "/");
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground");
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground");
|
||||||
|
|
||||||
|
Dictionary<string, object?> payload = new()
|
||||||
|
{
|
||||||
|
["model"] = model,
|
||||||
|
["messages"] = messages,
|
||||||
|
["tools"] = tools,
|
||||||
|
["tool_choice"] = "auto",
|
||||||
|
["provider"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["allow_fallbacks"] = options.AllowFallbacks,
|
||||||
|
["require_parameters"] = options.RequireProviderParameters
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ShouldSendParallelToolCallsParameter(model))
|
||||||
|
payload["parallel_tool_calls"] = !options.DisableParallelToolCalls;
|
||||||
|
|
||||||
|
using HttpResponseMessage response = await client.PostAsync(
|
||||||
|
"chat/completions",
|
||||||
|
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
string body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new InvalidOperationException($"OpenRouter returned HTTP {(int)response.StatusCode}: {body}");
|
||||||
|
|
||||||
|
using JsonDocument document = JsonDocument.Parse(body);
|
||||||
|
if (!document.RootElement.TryGetProperty("choices", out JsonElement choices)
|
||||||
|
|| choices.ValueKind != JsonValueKind.Array
|
||||||
|
|| choices.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OpenRouter did not return any choices.");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonElement message = choices[0].GetProperty("message");
|
||||||
|
string assistantContent = message.TryGetProperty("content", out JsonElement contentElement)
|
||||||
|
? contentElement.GetString() ?? string.Empty
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
List<MccModelToolCall> toolCalls = [];
|
||||||
|
if (message.TryGetProperty("tool_calls", out JsonElement toolCallsElement) && toolCallsElement.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (JsonElement toolCall in toolCallsElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!toolCall.TryGetProperty("id", out JsonElement idElement)
|
||||||
|
|| !toolCall.TryGetProperty("function", out JsonElement functionElement)
|
||||||
|
|| !functionElement.TryGetProperty("name", out JsonElement nameElement))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls.Add(new MccModelToolCall(
|
||||||
|
CallId: idElement.GetString() ?? Guid.NewGuid().ToString("n"),
|
||||||
|
Name: nameElement.GetString() ?? string.Empty,
|
||||||
|
ArgumentsJson: functionElement.TryGetProperty("arguments", out JsonElement argumentsElement)
|
||||||
|
? argumentsElement.GetString() ?? "{}"
|
||||||
|
: "{}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string modelId = document.RootElement.TryGetProperty("model", out JsonElement modelElement)
|
||||||
|
? modelElement.GetString() ?? model
|
||||||
|
: model;
|
||||||
|
|
||||||
|
string? routedProvider = response.Headers.TryGetValues("x-openrouter-provider", out IEnumerable<string>? providerValues)
|
||||||
|
? providerValues.FirstOrDefault()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new MccModelTurn(modelId, routedProvider, assistantContent, toolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldSendParallelToolCallsParameter(string model)
|
||||||
|
{
|
||||||
|
// Some OpenRouter model families reject tool-enabled requests when the parallel_tool_calls
|
||||||
|
// parameter is present at all, even if it is explicitly set to false. The harness still
|
||||||
|
// executes all returned tool calls sequentially, so omitting the transport hint for those
|
||||||
|
// families preserves the intended runtime behavior while keeping the stricter flag for
|
||||||
|
// compatible models.
|
||||||
|
return !model.StartsWith("minimax/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !model.StartsWith("google/gemini-", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MccModelTurn(
|
||||||
|
string ModelId,
|
||||||
|
string? RoutedProvider,
|
||||||
|
string AssistantContent,
|
||||||
|
IReadOnlyList<MccModelToolCall> ToolCalls);
|
||||||
|
|
||||||
|
public sealed record MccModelToolCall(string CallId, string Name, string ArgumentsJson);
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,5 +4,11 @@
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"MccWebHarness": {
|
||||||
|
"AllowFallbacks": false,
|
||||||
|
"DisableParallelToolCalls": true,
|
||||||
|
"ExposeInventoryWindowAction": false,
|
||||||
|
"ExposeInternalCommandTool": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,20 @@
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MccWebHarness": {
|
||||||
|
"OpenRouterBaseUrl": "https://openrouter.ai/api/v1",
|
||||||
|
"McpEndpoint": "http://127.0.0.1:33333/mcp",
|
||||||
|
"MaxTurns": 48,
|
||||||
|
"MaxToolCalls": 120,
|
||||||
|
"MaxWallClockSeconds": 240,
|
||||||
|
"SoftFinishRemainingTurns": 3,
|
||||||
|
"SoftFinishRemainingToolCalls": 8,
|
||||||
|
"SoftFinishRemainingSeconds": 30,
|
||||||
|
"RequireProviderParameters": true,
|
||||||
|
"AllowFallbacks": false,
|
||||||
|
"DisableParallelToolCalls": true,
|
||||||
|
"ExposeInventoryWindowAction": false,
|
||||||
|
"ExposeInternalCommandTool": false
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
310
DebugTools/MccMcpWebPlayground/wwwroot/app.js
Normal file
310
DebugTools/MccMcpWebPlayground/wwwroot/app.js
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
const html = document.documentElement;
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const sendBtn = document.getElementById("send");
|
||||||
|
const stopBtn = document.getElementById("stop");
|
||||||
|
const clearBtn = document.getElementById("clear");
|
||||||
|
const clearChatBtn = document.getElementById("clear-chat-btn");
|
||||||
|
const clearToolsBtn = document.getElementById("clear-tools-btn");
|
||||||
|
const promptEl = document.getElementById("prompt");
|
||||||
|
const chatEl = document.getElementById("chat");
|
||||||
|
const toolsEl = document.getElementById("tools");
|
||||||
|
const emptyStateEl = document.getElementById("empty-state");
|
||||||
|
const toolsEmptyStateEl = document.getElementById("tools-empty-state");
|
||||||
|
const typingIndicatorEl = document.getElementById("typing-indicator");
|
||||||
|
const themeToggleBtn = document.getElementById("theme-toggle");
|
||||||
|
const themeToggleIconEl = document.getElementById("theme-toggle-icon");
|
||||||
|
|
||||||
|
let history = [];
|
||||||
|
let activeAssistantBody = null;
|
||||||
|
let abortController = null;
|
||||||
|
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
|
||||||
|
loadTheme();
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
themeToggleBtn.addEventListener("click", () => {
|
||||||
|
const next = html.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
||||||
|
setTheme(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
sendBtn.addEventListener("click", sendPrompt);
|
||||||
|
stopBtn.addEventListener("click", () => abortController?.abort());
|
||||||
|
|
||||||
|
clearBtn.addEventListener("click", () => {
|
||||||
|
history = [];
|
||||||
|
removeAllMessages();
|
||||||
|
removeAllTimelineEvents();
|
||||||
|
promptEl.value = "";
|
||||||
|
activeAssistantBody = null;
|
||||||
|
updateEmptyStates();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearChatBtn.addEventListener("click", () => {
|
||||||
|
history = [];
|
||||||
|
removeAllMessages();
|
||||||
|
promptEl.value = "";
|
||||||
|
activeAssistantBody = null;
|
||||||
|
updateEmptyStates();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearToolsBtn.addEventListener("click", () => {
|
||||||
|
removeAllTimelineEvents();
|
||||||
|
updateEmptyStates();
|
||||||
|
});
|
||||||
|
|
||||||
|
promptEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
sendPrompt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/config");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await response.json();
|
||||||
|
const modelLabel = config.model ? config.model : "Model not configured";
|
||||||
|
statusEl.textContent = config.hasApiKey ? modelLabel : `${modelLabel} / missing OPENROUTER_API_KEY`;
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = `Config error: ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPrompt() {
|
||||||
|
const prompt = promptEl.value.trim();
|
||||||
|
if (!prompt || abortController) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({ role: "user", content: prompt });
|
||||||
|
addMessage("user", prompt);
|
||||||
|
promptEl.value = "";
|
||||||
|
activeAssistantBody = addMessage("assistant", "");
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/chat/stream", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages: history }),
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let finalAssistantText = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
buffer = parseSseChunk(buffer, (eventName, envelope) => {
|
||||||
|
addTimelineEvent(eventName, envelope);
|
||||||
|
|
||||||
|
if (eventName === "error") {
|
||||||
|
const errorMessage = envelope.data?.message ?? "Unknown error";
|
||||||
|
addMessage("error", errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName === "final") {
|
||||||
|
finalAssistantText = formatFinalText(envelope.data);
|
||||||
|
activeAssistantBody.textContent = finalAssistantText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName === "state_summary") {
|
||||||
|
const turnCount = envelope.data?.turnCount ?? "?";
|
||||||
|
const toolCallCount = envelope.data?.toolCallCount ?? "?";
|
||||||
|
statusEl.textContent = `Running turn ${turnCount}, tools ${toolCallCount}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalAssistantText.trim().length > 0) {
|
||||||
|
history.push({ role: "assistant", content: finalAssistantText });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError") {
|
||||||
|
addMessage("error", `Request failed: ${error.message}`);
|
||||||
|
addTimelineEvent("error", {
|
||||||
|
kind: "error",
|
||||||
|
data: {
|
||||||
|
code: "request_failed",
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortController = null;
|
||||||
|
activeAssistantBody = null;
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseChunk(buffer, onEvent) {
|
||||||
|
let blockIndex;
|
||||||
|
while ((blockIndex = buffer.indexOf("\n\n")) >= 0) {
|
||||||
|
const rawBlock = buffer.slice(0, blockIndex);
|
||||||
|
buffer = buffer.slice(blockIndex + 2);
|
||||||
|
|
||||||
|
let eventName = "message";
|
||||||
|
let dataText = "";
|
||||||
|
for (const line of rawBlock.split("\n")) {
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
eventName = line.slice(6).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataText += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataText) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
onEvent(eventName, JSON.parse(dataText));
|
||||||
|
} catch (error) {
|
||||||
|
onEvent("error", {
|
||||||
|
kind: "error",
|
||||||
|
data: {
|
||||||
|
code: "invalid_sse_payload",
|
||||||
|
message: "Failed to parse SSE payload.",
|
||||||
|
detail: dataText
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(role, content) {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = `message ${role}`;
|
||||||
|
|
||||||
|
const label = document.createElement("div");
|
||||||
|
label.className = "message-label";
|
||||||
|
label.textContent = role;
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "message-body";
|
||||||
|
body.textContent = content;
|
||||||
|
|
||||||
|
wrapper.append(label, body);
|
||||||
|
chatEl.insertBefore(wrapper, typingIndicatorEl);
|
||||||
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
updateEmptyStates();
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTimelineEvent(kind, envelope) {
|
||||||
|
const event = document.createElement("div");
|
||||||
|
event.className = `timeline-event kind-${kind}`;
|
||||||
|
|
||||||
|
const label = document.createElement("div");
|
||||||
|
label.className = "timeline-label";
|
||||||
|
label.textContent = kind.replaceAll("_", " ");
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "timeline-body-text";
|
||||||
|
body.textContent = JSON.stringify(envelope.data ?? envelope, null, 2);
|
||||||
|
|
||||||
|
event.append(label, body);
|
||||||
|
toolsEl.appendChild(event);
|
||||||
|
toolsEl.scrollTop = toolsEl.scrollHeight;
|
||||||
|
updateEmptyStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFinalText(data) {
|
||||||
|
if (!data) {
|
||||||
|
return "The run completed without a final payload.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
if (data.headline) {
|
||||||
|
lines.push(data.headline);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.answerMarkdown) {
|
||||||
|
lines.push(data.answerMarkdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.verifiedFacts) && data.verifiedFacts.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Verified facts:");
|
||||||
|
for (const fact of data.verifiedFacts) {
|
||||||
|
lines.push(`- ${fact}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.openIssues) && data.openIssues.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Open issues:");
|
||||||
|
for (const issue of data.openIssues) {
|
||||||
|
lines.push(`- ${issue}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.nextAction) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Next action: ${data.nextAction}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(busy) {
|
||||||
|
sendBtn.disabled = busy;
|
||||||
|
stopBtn.disabled = !busy;
|
||||||
|
promptEl.disabled = busy;
|
||||||
|
typingIndicatorEl.classList.toggle("visible", busy);
|
||||||
|
statusEl.classList.toggle("busy", busy);
|
||||||
|
if (!busy) {
|
||||||
|
loadConfig();
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = "Streaming run...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAllMessages() {
|
||||||
|
for (const message of chatEl.querySelectorAll(".message")) {
|
||||||
|
message.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAllTimelineEvents() {
|
||||||
|
for (const event of toolsEl.querySelectorAll(".timeline-event")) {
|
||||||
|
event.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEmptyStates() {
|
||||||
|
emptyStateEl.style.display = chatEl.querySelectorAll(".message").length === 0 ? "" : "none";
|
||||||
|
toolsEmptyStateEl.style.display = toolsEl.querySelectorAll(".timeline-event").length === 0 ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTheme() {
|
||||||
|
const theme = localStorage.getItem("mcc-playground-theme") || "dark";
|
||||||
|
setTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
html.setAttribute("data-theme", theme);
|
||||||
|
themeToggleIconEl.textContent = theme === "dark" ? "◎" : "◐";
|
||||||
|
localStorage.setItem("mcc-playground-theme", theme);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
383
DebugTools/MccMcpWebPlayground/wwwroot/site.css
Normal file
383
DebugTools/MccMcpWebPlayground/wwwroot/site.css
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
:root {
|
||||||
|
--bg: #07111e;
|
||||||
|
--bg-alt: #0b1828;
|
||||||
|
--panel: rgba(10, 21, 36, 0.88);
|
||||||
|
--panel-strong: rgba(8, 18, 30, 0.96);
|
||||||
|
--border: rgba(111, 179, 255, 0.18);
|
||||||
|
--text: #dce9ff;
|
||||||
|
--text-dim: #8ca4c8;
|
||||||
|
--text-soft: #607695;
|
||||||
|
--accent: #75e7c7;
|
||||||
|
--accent-strong: #4ad3ff;
|
||||||
|
--warning: #ffcc66;
|
||||||
|
--danger: #ff7b8b;
|
||||||
|
--shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #edf4ff;
|
||||||
|
--bg-alt: #dfeaff;
|
||||||
|
--panel: rgba(255, 255, 255, 0.88);
|
||||||
|
--panel-strong: rgba(255, 255, 255, 0.96);
|
||||||
|
--border: rgba(28, 89, 164, 0.14);
|
||||||
|
--text: #172843;
|
||||||
|
--text-dim: #4d6383;
|
||||||
|
--text-soft: #7d90ad;
|
||||||
|
--accent: #0f936d;
|
||||||
|
--accent-strong: #006cbb;
|
||||||
|
--warning: #a56700;
|
||||||
|
--danger: #ba2741;
|
||||||
|
--shadow: 0 20px 60px rgba(61, 89, 138, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Space Mono", monospace;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(74, 211, 255, 0.12), transparent 35%),
|
||||||
|
radial-gradient(circle at right center, rgba(117, 231, 199, 0.08), transparent 40%),
|
||||||
|
linear-gradient(160deg, var(--bg), var(--bg-alt));
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.panel,
|
||||||
|
.composer {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
box-shadow: 0 0 18px rgba(117, 231, 199, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-family: "Syne", sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.busy {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-color: rgba(74, 211, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button,
|
||||||
|
.ghost-button,
|
||||||
|
.primary-button {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover,
|
||||||
|
.ghost-button:hover,
|
||||||
|
.primary-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(117, 231, 199, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
color: #06101a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 0.9fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Syne", sans-serif;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-body,
|
||||||
|
.timeline-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message,
|
||||||
|
.timeline-event {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user {
|
||||||
|
background: rgba(74, 211, 255, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant {
|
||||||
|
background: rgba(117, 231, 199, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background: rgba(255, 123, 139, 0.08);
|
||||||
|
border-color: rgba(255, 123, 139, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-label,
|
||||||
|
.timeline-label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body,
|
||||||
|
.timeline-body-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event.kind-tool_called {
|
||||||
|
border-left: 4px solid var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event.kind-tool_result {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event.kind-error {
|
||||||
|
border-left: 4px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event.kind-budget {
|
||||||
|
border-left: 4px solid var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event.kind-final {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 30px 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-soft);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: none;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
animation: bounce 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(2) {
|
||||||
|
animation-delay: 0.16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(3) {
|
||||||
|
animation-delay: 0.32s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 78px;
|
||||||
|
max-height: 240px;
|
||||||
|
resize: vertical;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt:focus {
|
||||||
|
outline: 2px solid rgba(74, 211, 255, 0.35);
|
||||||
|
border-color: rgba(74, 211, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-footer {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-hint {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.composer {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions > button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
@ -25,6 +26,8 @@ public sealed class MccMcpGuidanceProvider
|
||||||
|
|
||||||
public string PromptName => "mcc_operator_prompt";
|
public string PromptName => "mcc_operator_prompt";
|
||||||
|
|
||||||
|
public string SkillName => "mcc-mcp-operator";
|
||||||
|
|
||||||
public string GetSystemPrompt()
|
public string GetSystemPrompt()
|
||||||
{
|
{
|
||||||
GuidanceDocument document = guidanceDocument.Value;
|
GuidanceDocument document = guidanceDocument.Value;
|
||||||
|
|
@ -51,6 +54,9 @@ public sealed class MccMcpGuidanceProvider
|
||||||
{
|
{
|
||||||
PromptName = PromptName,
|
PromptName = PromptName,
|
||||||
PromptMarkdown = document.PromptMarkdown,
|
PromptMarkdown = document.PromptMarkdown,
|
||||||
|
SkillName = SkillName,
|
||||||
|
GuidanceVersion = document.GuidanceVersion,
|
||||||
|
SkillMarkdown = document.SkillMarkdown,
|
||||||
SystemPrompt = GetSystemPrompt(),
|
SystemPrompt = GetSystemPrompt(),
|
||||||
BestPractices = document.BestPractices,
|
BestPractices = document.BestPractices,
|
||||||
ExampleScenarios = document.ExampleScenarios,
|
ExampleScenarios = document.ExampleScenarios,
|
||||||
|
|
@ -76,6 +82,7 @@ public sealed class MccMcpGuidanceProvider
|
||||||
string exampleScenariosSection = ExtractSection(bodyMarkdown, ExampleScenariosHeading);
|
string exampleScenariosSection = ExtractSection(bodyMarkdown, ExampleScenariosHeading);
|
||||||
|
|
||||||
return new GuidanceDocument(
|
return new GuidanceDocument(
|
||||||
|
ComputeGuidanceVersion(promptMarkdown),
|
||||||
promptMarkdown.Replace("\r\n", "\n").Trim(),
|
promptMarkdown.Replace("\r\n", "\n").Trim(),
|
||||||
bodyMarkdown,
|
bodyMarkdown,
|
||||||
ExtractBulletList(bestPracticesSection),
|
ExtractBulletList(bestPracticesSection),
|
||||||
|
|
@ -180,11 +187,22 @@ public sealed class MccMcpGuidanceProvider
|
||||||
return enabled ? "enabled" : "disabled";
|
return enabled ? "enabled" : "disabled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ComputeGuidanceVersion(string markdown)
|
||||||
|
{
|
||||||
|
byte[] bytes = Encoding.UTF8.GetBytes(markdown.Replace("\r\n", "\n").Trim());
|
||||||
|
byte[] hash = SHA256.HashData(bytes);
|
||||||
|
return Convert.ToHexString(hash[..8]).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record GuidanceDocument(
|
private sealed record GuidanceDocument(
|
||||||
string PromptMarkdown,
|
string GuidanceVersion,
|
||||||
|
string SkillMarkdown,
|
||||||
string BodyMarkdown,
|
string BodyMarkdown,
|
||||||
string[] BestPractices,
|
string[] BestPractices,
|
||||||
MccMcpAgentScenario[] ExampleScenarios);
|
MccMcpAgentScenario[] ExampleScenarios)
|
||||||
|
{
|
||||||
|
public string PromptMarkdown => SkillMarkdown;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class MccMcpAgentGuidancePayload
|
public sealed class MccMcpAgentGuidancePayload
|
||||||
|
|
@ -195,6 +213,15 @@ public sealed class MccMcpAgentGuidancePayload
|
||||||
[JsonPropertyName("promptMarkdown")]
|
[JsonPropertyName("promptMarkdown")]
|
||||||
public string PromptMarkdown { get; init; } = string.Empty;
|
public string PromptMarkdown { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("skillName")]
|
||||||
|
public string SkillName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("guidanceVersion")]
|
||||||
|
public string GuidanceVersion { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("skillMarkdown")]
|
||||||
|
public string SkillMarkdown { get; init; } = string.Empty;
|
||||||
|
|
||||||
[JsonPropertyName("systemPrompt")]
|
[JsonPropertyName("systemPrompt")]
|
||||||
public string SystemPrompt { get; init; } = string.Empty;
|
public string SystemPrompt { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue