From ee6eb84bd881b7fdfd3aaaa09fa2b68753f5c773 Mon Sep 17 00:00:00 2001 From: milutinke Date: Wed, 1 Apr 2026 12:49:07 +0200 Subject: [PATCH] Improved the Web Based Harness --- .../Api/MccPlaygroundEndpoints.cs | 36 + .../Contracts/MccContracts.cs | 94 ++ .../Harness/MccAgentRunService.cs | 852 ++++++++++++ .../Harness/MccContextCompressor.cs | 21 + .../Harness/MccFinalizer.cs | 221 ++++ .../Harness/MccGuidanceSource.cs | 63 + .../Harness/MccPromptComposer.cs | 86 ++ .../Harness/MccRunState.cs | 103 ++ .../Harness/MccToolPolicy.cs | 133 ++ .../Harness/MccWebHarnessOptions.cs | 58 + .../Mcp/MccMcpSessionFactory.cs | 200 +++ .../OpenRouter/OpenRouterChatClient.cs | 120 ++ DebugTools/MccMcpWebPlayground/Program.cs | 1149 +---------------- .../appsettings.Development.json | 6 + .../MccMcpWebPlayground/appsettings.json | 15 + DebugTools/MccMcpWebPlayground/wwwroot/app.js | 310 +++++ .../MccMcpWebPlayground/wwwroot/index.html | 1120 +--------------- .../MccMcpWebPlayground/wwwroot/site.css | 383 ++++++ MinecraftClient/Mcp/MccMcpGuidanceProvider.cs | 31 +- 19 files changed, 2799 insertions(+), 2202 deletions(-) create mode 100644 DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs create mode 100644 DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccContextCompressor.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccWebHarnessOptions.cs create mode 100644 DebugTools/MccMcpWebPlayground/Infrastructure/Mcp/MccMcpSessionFactory.cs create mode 100644 DebugTools/MccMcpWebPlayground/Infrastructure/OpenRouter/OpenRouterChatClient.cs create mode 100644 DebugTools/MccMcpWebPlayground/wwwroot/app.js create mode 100644 DebugTools/MccMcpWebPlayground/wwwroot/site.css diff --git a/DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs b/DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs new file mode 100644 index 00000000..ec3e0f83 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs @@ -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 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; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs b/DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs new file mode 100644 index 00000000..2257e1b1 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Serialization; + +namespace DebugTools.MccMcpWebPlayground.Contracts; + +public sealed class ChatStreamRequest +{ + public List? 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 OpenVerification, + IReadOnlyList 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 VerifiedFacts, + IReadOnlyList OpenIssues, + IReadOnlyList EvidenceIds, + string? NextAction); + +public sealed record MccSubmitFinalArgs( + string Status, + string Headline, + string AnswerMarkdown, + IReadOnlyList VerifiedFacts, + IReadOnlyList OpenIssues, + IReadOnlyList 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); diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs b/DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs new file mode 100644 index 00000000..33259389 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs @@ -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> 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 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> 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 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 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 + { + ["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 + { + ["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 assistantMessage = new() + { + ["role"] = "assistant", + ["content"] = turn.AssistantContent, + ["tool_calls"] = turn.ToolCalls.Select(call => new Dictionary + { + ["id"] = call.CallId, + ["type"] = "function", + ["function"] = new Dictionary + { + ["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 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 NormalizeConversation(List? incoming) + { + List 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 + { + ["role"] = role, + ["content"] = message.Content.Trim() + }); + } + + return messages; + } + + private static string ExtractUserRequest(List? incoming) + { + return incoming? + .LastOrDefault(message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(message.Content)) + ?.Content + ?.Trim() + ?? string.Empty; + } + + private static Dictionary BuildToolMessage(string callId, string content) + { + return new Dictionary + { + ["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 CreateObligations(MccRunState runState, MccEvidenceRecord evidence, string argumentsJson) + { + List 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 TryClearObligationsFromEvidence(MccRunState runState, MccEvidenceRecord evidence) + { + List 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 CreateEvent(string runId, ref long sequence, string kind, T data) + { + sequence++; + return new SseItem( + new MccStreamEnvelope(runId, sequence, kind, data!), + kind) + { + EventId = sequence.ToString(CultureInfo.InvariantCulture) + }; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccContextCompressor.cs b/DebugTools/MccMcpWebPlayground/Harness/MccContextCompressor.cs new file mode 100644 index 00000000..1d131bda --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccContextCompressor.cs @@ -0,0 +1,21 @@ +namespace DebugTools.MccMcpWebPlayground.Harness; + +public sealed class MccContextCompressor +{ + public void CompactIfNeeded(MccRunState runState) + { + if (runState.Evidence.Count <= 6) + return; + + IReadOnlyList 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}")); + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs b/DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs new file mode 100644 index 00000000..88978bc6 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs @@ -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 + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["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 evidenceById = runState.Evidence.ToDictionary(record => record.Id, StringComparer.OrdinalIgnoreCase); + Dictionary 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 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 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 evidenceIds = runState.Evidence.TakeLast(4).Select(record => record.Id).ToArray(); + IReadOnlyList 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 verifiedFacts, + IReadOnlyList evidenceIds, + IReadOnlyDictionary 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 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() + .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); +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs b/DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs new file mode 100644 index 00000000..a302e3f0 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs @@ -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 LoadAsync(McpClient client, CancellationToken cancellationToken) + { + CallToolResult result = await client.CallToolAsync(SourceToolName, new Dictionary(), 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() + .ToArray() + : []; + + MccCapabilityStatus capabilityStatus = data.TryGetProperty("capabilityStatus", out JsonElement capabilityJson) + ? JsonSerializer.Deserialize(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() + .ToArray() + : []; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs b/DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs new file mode 100644 index 00000000..b9cb4650 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs @@ -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 Compose(MccRunState runState) + { + List 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 BuildSystemMessage(string text) + { + return new Dictionary + { + ["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} +"""; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs b/DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs new file mode 100644 index 00000000..f226c8b3 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs @@ -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 BaseConversationMessages { get; init; } + public required string ConfiguredModel { get; init; } + public required MccGuidanceBundle Guidance { get; init; } + public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow; + + public List ToolConversationMessages { get; } = []; + public List Evidence { get; } = []; + public List ToolExecutions { get; } = []; + public List 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 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); diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs b/DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs new file mode 100644 index 00000000..e8a9bd77 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs @@ -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 ToolsByName { get; init; } + public required IReadOnlyList ModelVisibleTools { get; init; } +} + +public static class MccToolPolicy +{ + private static readonly FrozenDictionary Profiles = + new Dictionary(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 tools, MccWebHarnessOptions options, object submitFinalTool) + { + Dictionary toolsByName = tools.ToDictionary( + tool => tool.Name, + tool => new MccToolCatalogEntry(tool, GetProfile(tool.Name)), + StringComparer.OrdinalIgnoreCase); + + List 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 + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = tool.Name, + ["description"] = description, + ["parameters"] = parameters + } + }; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccWebHarnessOptions.cs b/DebugTools/MccMcpWebPlayground/Harness/MccWebHarnessOptions.cs new file mode 100644 index 00000000..0855c471 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccWebHarnessOptions.cs @@ -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(); + } +} diff --git a/DebugTools/MccMcpWebPlayground/Infrastructure/Mcp/MccMcpSessionFactory.cs b/DebugTools/MccMcpWebPlayground/Infrastructure/Mcp/MccMcpSessionFactory.cs new file mode 100644 index 00000000..8775bb2c --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Infrastructure/Mcp/MccMcpSessionFactory.cs @@ -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 options) + { + this.options = options.Value; + } + + public async Task 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 + { + ["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 Parse(string rawJson) + { + try + { + using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(rawJson) ? "{}" : rawJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return new Dictionary(); + + Dictionary values = new(StringComparer.OrdinalIgnoreCase); + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + values[property.Name] = Convert(property.Value); + return values; + } + catch + { + return new Dictionary(); + } + } + + 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() + }; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Infrastructure/OpenRouter/OpenRouterChatClient.cs b/DebugTools/MccMcpWebPlayground/Infrastructure/OpenRouter/OpenRouterChatClient.cs new file mode 100644 index 00000000..24d9a080 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Infrastructure/OpenRouter/OpenRouterChatClient.cs @@ -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 CreateTurnAsync( + List messages, + IReadOnlyList 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 payload = new() + { + ["model"] = model, + ["messages"] = messages, + ["tools"] = tools, + ["tool_choice"] = "auto", + ["provider"] = new Dictionary + { + ["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 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? 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 ToolCalls); + +public sealed record MccModelToolCall(string CallId, string Name, string ArgumentsJson); diff --git a/DebugTools/MccMcpWebPlayground/Program.cs b/DebugTools/MccMcpWebPlayground/Program.cs index 9b25f0ca..17060301 100644 --- a/DebugTools/MccMcpWebPlayground/Program.cs +++ b/DebugTools/MccMcpWebPlayground/Program.cs @@ -1,1133 +1,40 @@ -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; +using DebugTools.MccMcpWebPlayground.Api; +using DebugTools.MccMcpWebPlayground.Harness; +using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp; +using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter; +using Microsoft.AspNetCore.Http.Timeouts; var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(MccWebHarnessOptions.SectionName)); + +builder.Services.AddRequestTimeouts(options => +{ + options.AddPolicy("mcc-stream", new RequestTimeoutPolicy + { + Timeout = TimeSpan.FromMinutes(10) + }); +}); + builder.Services.AddHttpClient("openrouter", client => { client.Timeout = TimeSpan.FromMinutes(15); }); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + var app = builder.Build(); + +app.UseRequestTimeouts(); app.UseDefaultFiles(); app.UseStaticFiles(); - -const string AgentSystemPrompt = """ -You are an agent controlling Minecraft Console Client (MCC) through MCP tools. -Use a plan-execute-verify loop. - -Operating mode -- For simple social turns like "hello" or "thanks", do not waste tool calls. Finish directly unless MCC state is required. -- For MCC questions and actions, think in steps and use tools to gather evidence before you finish. -- Never output plain assistant text before calling agent_finish(answer). - -Planning policy -- If the task is multi-step or physical, first decompose it into a short internal plan. -- Prefer the smallest plan that can succeed. -- For long or branchy tasks, keep a short checklist and update it as you go. -- Default sequence: - 1) inspect current state - 2) locate the target - 3) move into a valid position if needed - 4) perform the action - 5) verify with fresh tool calls - 6) call agent_finish(answer) -- If a step fails, revise the plan using the latest observation. Do not blindly repeat the same failing action. - -Todo policy -- Use todo_write, todo_read, and todo_list for tasks with 4 or more steps, retries, or branching verification. -- Keep todos short, concrete, and action-oriented. -- Update todo status as facts change. -- Todo state is request-scoped for the current chat request only. -- Skip todo tools for simple one-step tasks. - -Tool-use policy -- Use MCP tools for MCC/game-state questions and actions. -- Prefer the most direct high-signal tool first. -- Prefer structured inventory/container tools over raw window-click tools for chest or container management. -- If a tool result says success=false or includes an errorCode, treat that as a failed observation even if the transport call itself succeeded. -- Do not guess tool arguments repeatedly. If a tool returns invalid_args: - - simplify to the minimum required arguments, - - try at most one nearby variant, - - or switch to a broader inspection tool. -- Avoid long speculative tool chains. - -Verification policy -- Never claim success from intent alone. -- Never claim movement succeeded just because a move command was accepted. Check arrived or a fresh location result. -- Never claim an item was collected unless inventory or nearby entity state changed. -- Never claim blocks were removed unless block/world search results changed. -- If evidence is partial, say it is partial. -- If the request cannot be completed, say exactly what was verified and what remains unverified. - -Action-specific guidance -- Move or approach: - - locate the target, - - choose a reachable nearby standing position when exact occupancy is risky, - - move, - - verify arrival before finishing. -- Dig or collect: - - locate the blocks, - - move next to them if needed, - - dig in a sensible order, - - re-check remaining blocks, - - re-check inventory or nearby item entities before finishing. -- Container inventory: - - locate the target container block, - - open the container first, - - inspect player and container inventory state, - - use structured deposit or withdraw tools instead of raw window clicks, - - verify both player and container counts changed before finishing. -- Search: - - start with the most direct search tool, - - use the user's requested radius when supported, - - if a query fails, simplify it instead of trying many near-duplicates. - -Good examples -1) User: "Pick up those logs." - Good: - - if the task looks long, write a short todo list - - find the logs - - move next to them - - dig them - - verify the logs are gone or reduced - - verify inventory increased - - then finish -2) User: "Is Zarko near you?" - Good: - - call a nearby-player tool - - report the matched player and distance - - then finish -3) User: "Hello" - Good: - - finish with a short greeting - - no MCP tools -4) User: "Put 5 diamonds in the chest." - Good: - - open the chest - - inspect inventory state - - deposit exactly 5 diamonds - - verify the chest count increased and player count decreased by 5 - - then finish - -Wrong examples -1) Wrong: - - inventory did not change - - blocks may still exist - - but you still say "I picked them up" -2) Wrong: - - move returns pathFound=true but arrived=false - - and you still say "I walked there" -3) Wrong: - - a tool returns invalid_args several times - - and you keep guessing similar argument combinations -4) Wrong: - - you write assistant prose before agent_finish(answer) - -Finish rules -- Complete only by calling agent_finish(answer). -- The final answer must be natural language for a human and include exactly: - Reasoning: - - brief bullets with the important verified observations - Answer: - - direct user-facing result with uncertainty stated when relevant -"""; - -const string BudgetReminderPrompt = """ -Budget is nearly exhausted. -Use the strongest verified evidence you already have. -Do not start speculative new branches. -If the task is complete or partially complete, call agent_finish(answer) now and clearly distinguish verified facts from unverified assumptions. -Do not output plain assistant text before finishing. -"""; - -app.MapGet("/api/health", () => Results.Ok(new { ok = true })); -app.MapGet("/api/config", () => -{ - return Results.Ok(new - { - model = GetModel(), - openRouterBaseUrl = GetOpenRouterBaseUrl(), - mcpEndpoint = GetMcpEndpoint(), - hasApiKey = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENROUTER_API_KEY")) - }); -}); - -app.MapPost("/api/chat/stream", async (ChatStreamRequest request, IHttpClientFactory httpClientFactory, HttpContext context, CancellationToken cancellationToken) => -{ - context.Response.StatusCode = StatusCodes.Status200OK; - context.Response.ContentType = "text/event-stream"; - context.Response.Headers.CacheControl = "no-cache"; - context.Response.Headers["X-Accel-Buffering"] = "no"; - - try - { - string? apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); - if (string.IsNullOrWhiteSpace(apiKey)) - { - await WriteEvent(context.Response, "error", new { message = "OPENROUTER_API_KEY is not set." }, cancellationToken); - return; - } - - List messages = BuildMessages(request.Messages); - if (messages.Count == 0) - { - await WriteEvent(context.Response, "error", new { message = "No messages provided." }, cancellationToken); - return; - } - - string model = GetModel(); - int maxIterations = GetBoundedInt("MCC_WEB_MAX_ITERATIONS", 96, 4, 256); - int maxToolCalls = GetBoundedInt("MCC_WEB_MAX_TOOL_CALLS", 320, 4, 1024); - TimeSpan maxWallTime = TimeSpan.FromSeconds(GetBoundedInt("MCC_WEB_MAX_SECONDS", 900, 10, 3600)); - - await using McpClient mcp = await CreateMcpClientAsync(cancellationToken); - IList mcpTools = await mcp.ListToolsAsync(cancellationToken: cancellationToken); - Dictionary mcpToolsByName = mcpTools - .ToDictionary(tool => tool.Name, StringComparer.OrdinalIgnoreCase); - - object[] openRouterTools = - [ - .. mcpTools.Select(ToOpenRouterTool), - BuildTodoWriteToolSchema(), - BuildTodoReadToolSchema(), - BuildTodoListToolSchema(), - BuildAgentFinishToolSchema() - ]; - - using HttpClient openRouter = httpClientFactory.CreateClient("openrouter"); - openRouter.BaseAddress = new Uri(GetOpenRouterBaseUrl().TrimEnd('/') + "/"); - openRouter.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); - openRouter.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground"); - openRouter.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground"); - - Stopwatch wallClock = Stopwatch.StartNew(); - int toolCallCount = 0; - bool reminderInjected = false; - string? finalAnswer = null; - List observations = new(); - Dictionary todos = new(StringComparer.OrdinalIgnoreCase); - int nextTodoOrder = 0; - - for (int iteration = 1; iteration <= maxIterations && !cancellationToken.IsCancellationRequested; iteration++) - { - if (!reminderInjected && ShouldInjectReminder(iteration, maxIterations, toolCallCount, maxToolCalls, wallClock.Elapsed, maxWallTime)) - { - messages.Add(new Dictionary - { - ["role"] = "system", - ["content"] = BudgetReminderPrompt - }); - reminderInjected = true; - } - - if (wallClock.Elapsed >= maxWallTime || toolCallCount >= maxToolCalls) - break; - - JsonElement choiceMessage = await RequestToolIterationAsync(openRouter, model, messages, openRouterTools, context.Response, cancellationToken); - if (choiceMessage.ValueKind == JsonValueKind.Undefined) - return; - - string assistantContent = choiceMessage.TryGetProperty("content", out JsonElement contentElement) - ? contentElement.GetString() ?? string.Empty - : string.Empty; - - if (choiceMessage.TryGetProperty("tool_calls", out JsonElement toolCallsElement) - && toolCallsElement.ValueKind == JsonValueKind.Array - && toolCallsElement.GetArrayLength() > 0) - { - List toolCallsForHistory = new(); - List toolMessages = new(); - bool stopLoop = false; - - foreach (JsonElement toolCall in toolCallsElement.EnumerateArray()) - { - if (!TryReadToolCall(toolCall, out string callId, out string toolName, out string argumentsRaw)) - continue; - - toolCallsForHistory.Add(new Dictionary - { - ["id"] = callId, - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = toolName, - ["arguments"] = argumentsRaw - } - }); - - await WriteEvent(context.Response, "tool_call", new - { - id = callId, - name = toolName, - arguments = argumentsRaw - }, cancellationToken); - - if (TryHandleLocalToolCall(toolName, argumentsRaw, todos, ref nextTodoOrder, out bool localIsError, out string localResultText, out string? completedAnswer)) - { - await WriteEvent(context.Response, "tool_result", new - { - id = callId, - name = toolName, - isError = localIsError, - content = localResultText - }, cancellationToken); - - toolMessages.Add(new Dictionary - { - ["role"] = "tool", - ["tool_call_id"] = callId, - ["content"] = localResultText - }); - - toolCallCount++; - observations.Add(SummarizeObservation(toolName, localResultText, localIsError)); - - if (completedAnswer is not null) - { - finalAnswer = EnsureFinalAnswerFormat(completedAnswer, observations); - stopLoop = true; - break; - } - - continue; - } - - if (!mcpToolsByName.ContainsKey(toolName)) - { - string resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "unknown_tool", - message = $"Unknown tool '{toolName}'." - }); - await WriteEvent(context.Response, "tool_result", new - { - id = callId, - name = toolName, - isError = true, - content = resultText - }, cancellationToken); - - observations.Add($"Tool {toolName} was rejected because it is unknown."); - toolMessages.Add(new Dictionary - { - ["role"] = "tool", - ["tool_call_id"] = callId, - ["content"] = resultText - }); - continue; - } - - if (toolCallCount >= maxToolCalls) - { - stopLoop = true; - break; - } - - bool isError = false; - string toolResultText; - try - { - Dictionary arguments = ParseArguments(argumentsRaw); - CallToolResult toolResult = await mcp.CallToolAsync(toolName, arguments, cancellationToken: cancellationToken); - toolResultText = ReadToolResultText(toolResult); - isError = toolResult.IsError == true || InferStructuredToolError(toolResultText); - } - catch (Exception ex) - { - isError = true; - toolResultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "tool_call_failed", - message = ex.Message - }); - } - - toolCallCount++; - observations.Add(SummarizeObservation(toolName, toolResultText, isError)); - await WriteEvent(context.Response, "tool_result", new - { - id = callId, - name = toolName, - isError, - content = toolResultText - }, cancellationToken); - - toolMessages.Add(new Dictionary - { - ["role"] = "tool", - ["tool_call_id"] = callId, - ["content"] = toolResultText - }); - } - - messages.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = assistantContent, - ["tool_calls"] = toolCallsForHistory - }); - foreach (object toolMessage in toolMessages) - messages.Add(toolMessage); - - if (finalAnswer is not null || stopLoop) - break; - - continue; - } - - if (!string.IsNullOrWhiteSpace(assistantContent)) - observations.Add($"Model attempted direct text before finishing: {Truncate(assistantContent, 140)}"); - - messages.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = assistantContent - }); - messages.Add(new Dictionary - { - ["role"] = "system", - ["content"] = "Do not return assistant prose yet. Continue with tool calls and end only by calling agent_finish(answer)." - }); - } - - finalAnswer ??= BuildForcedFinalAnswer(observations, toolCallCount, wallClock.Elapsed, maxIterations, maxToolCalls, maxWallTime); - await StreamFinalAnswer(context.Response, finalAnswer, cancellationToken); - } - catch (OperationCanceledException) - { - await WriteEvent(context.Response, "error", new { message = "Request cancelled." }, CancellationToken.None); - } - catch (Exception ex) - { - await WriteEvent(context.Response, "error", new - { - message = "Unhandled server error.", - detail = ex.Message - }, CancellationToken.None); - } -}); +app.MapMccPlaygroundEndpoints(); app.Run(); - -static string GetModel() -{ - return Environment.GetEnvironmentVariable("OPENROUTER_MODEL") ?? "minimax/minimax-m2.7"; -} - -static string GetOpenRouterBaseUrl() -{ - return Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL") ?? "https://openrouter.ai/api/v1"; -} - -static string GetMcpEndpoint() -{ - return Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp"; -} - -static async Task CreateMcpClientAsync(CancellationToken cancellationToken) -{ - string endpoint = GetMcpEndpoint(); - string? token = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN"); - - return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions - { - Endpoint = new Uri(endpoint), - TransportMode = HttpTransportMode.AutoDetect, - AdditionalHeaders = string.IsNullOrWhiteSpace(token) - ? null - : new Dictionary { ["Authorization"] = $"Bearer {token}" } - }), cancellationToken: cancellationToken); -} - -List BuildMessages(List? incoming) -{ - List messages = - [ - new Dictionary - { - ["role"] = "system", - ["content"] = AgentSystemPrompt - } - ]; - - 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 ("system" or "user" or "assistant")) - continue; - - messages.Add(new Dictionary - { - ["role"] = role, - ["content"] = message.Content - }); - } - - return messages; -} - -static object ToOpenRouterTool(McpClientTool tool) -{ - JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject() - }; - - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = tool.Name, - ["description"] = tool.Description, - ["parameters"] = parameters - } - }; -} - -static object BuildAgentFinishToolSchema() -{ - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = "agent_finish", - ["description"] = "Finalize the response to the user after all required tool calls and verification are done.", - ["parameters"] = new Dictionary - { - ["type"] = "object", - ["properties"] = new Dictionary - { - ["answer"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Final natural-language response for the user." - } - }, - ["required"] = new[] { "answer" }, - ["additionalProperties"] = false - } - } - }; -} - -static object BuildTodoWriteToolSchema() -{ - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = "todo_write", - ["description"] = "Create or update a short request-scoped todo item for complex task tracking.", - ["parameters"] = new Dictionary - { - ["type"] = "object", - ["properties"] = new Dictionary - { - ["id"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Stable todo identifier, for example move_to_logs or verify_inventory." - }, - ["content"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Short actionable todo text. Required when creating a new item." - }, - ["status"] = new Dictionary - { - ["type"] = "string", - ["description"] = "One of pending, in_progress, completed, blocked, cancelled." - }, - ["notes"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Optional brief note with the latest observation." - } - }, - ["required"] = new[] { "id" }, - ["additionalProperties"] = false - } - } - }; -} - -static object BuildTodoReadToolSchema() -{ - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = "todo_read", - ["description"] = "Read one request-scoped todo item by id.", - ["parameters"] = new Dictionary - { - ["type"] = "object", - ["properties"] = new Dictionary - { - ["id"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Todo identifier." - } - }, - ["required"] = new[] { "id" }, - ["additionalProperties"] = false - } - } - }; -} - -static object BuildTodoListToolSchema() -{ - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = "todo_list", - ["description"] = "List all request-scoped todo items in creation order.", - ["parameters"] = new Dictionary - { - ["type"] = "object", - ["properties"] = new Dictionary(), - ["additionalProperties"] = false - } - } - }; -} - -static bool TryHandleLocalToolCall( - string toolName, - string argumentsRaw, - Dictionary todos, - ref int nextTodoOrder, - out bool isError, - out string resultText, - out string? completedAnswer) -{ - isError = false; - resultText = string.Empty; - completedAnswer = null; - - if (toolName.Equals("agent_finish", StringComparison.OrdinalIgnoreCase)) - { - completedAnswer = ParseAgentFinishAnswer(argumentsRaw); - resultText = JsonSerializer.Serialize(new - { - success = true, - finished = true - }); - return true; - } - - if (toolName.Equals("todo_list", StringComparison.OrdinalIgnoreCase)) - { - resultText = JsonSerializer.Serialize(new - { - success = true, - data = new - { - count = todos.Count, - items = todos.Values - .OrderBy(item => item.Order) - .Select(ToTodoDto) - .ToArray() - } - }); - return true; - } - - Dictionary arguments = ParseArguments(argumentsRaw); - if (toolName.Equals("todo_read", StringComparison.OrdinalIgnoreCase)) - { - string? id = ReadOptionalStringArgument(arguments, "id"); - if (string.IsNullOrWhiteSpace(id)) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_args", - message = "todo_read requires a non-empty id." - }); - return true; - } - - if (!todos.TryGetValue(id, out TodoEntry? item)) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_state", - message = $"Todo '{id}' does not exist." - }); - return true; - } - - resultText = JsonSerializer.Serialize(new - { - success = true, - data = new - { - item = ToTodoDto(item) - } - }); - return true; - } - - if (!toolName.Equals("todo_write", StringComparison.OrdinalIgnoreCase)) - return false; - - string? todoId = ReadOptionalStringArgument(arguments, "id"); - if (string.IsNullOrWhiteSpace(todoId)) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_args", - message = "todo_write requires a non-empty id." - }); - return true; - } - - todos.TryGetValue(todoId, out TodoEntry? existingItem); - string? rawContent = ReadOptionalStringArgument(arguments, "content"); - string content = string.IsNullOrWhiteSpace(rawContent) - ? existingItem?.Content ?? string.Empty - : rawContent.Trim(); - if (content.Length == 0) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_args", - message = "todo_write requires content when creating a new item." - }); - return true; - } - - string requestedStatus = ReadOptionalStringArgument(arguments, "status") ?? existingItem?.Status ?? "pending"; - if (!TryNormalizeTodoStatus(requestedStatus, out string normalizedStatus)) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_args", - message = "Invalid todo status.", - data = new - { - status = requestedStatus, - allowed = GetTodoStatusValues() - } - }); - return true; - } - - string? notes = ReadOptionalStringArgument(arguments, "notes") ?? existingItem?.Notes; - TodoEntry entry = existingItem ?? new TodoEntry - { - Id = todoId, - Order = ++nextTodoOrder - }; - entry.Content = content; - entry.Status = normalizedStatus; - entry.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); - todos[todoId] = entry; - - resultText = JsonSerializer.Serialize(new - { - success = true, - data = new - { - item = ToTodoDto(entry), - totalCount = todos.Count - } - }); - return true; -} - -static async Task RequestToolIterationAsync( - HttpClient openRouter, - string model, - List messages, - object[] tools, - HttpResponse response, - CancellationToken cancellationToken) -{ - var payload = new Dictionary - { - ["model"] = model, - ["messages"] = messages, - ["tools"] = tools, - ["tool_choice"] = "auto" - }; - - using HttpResponseMessage completion = await openRouter.PostAsync( - "chat/completions", - new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), - cancellationToken); - - string body = await completion.Content.ReadAsStringAsync(cancellationToken); - if (!completion.IsSuccessStatusCode) - { - await WriteEvent(response, "error", new - { - message = "OpenRouter request failed.", - statusCode = (int)completion.StatusCode, - body - }, cancellationToken); - return default; - } - - using JsonDocument doc = JsonDocument.Parse(body); - if (!TryGetFirstChoiceMessage(doc.RootElement, out JsonElement message)) - { - await WriteEvent(response, "error", new { message = "No completion choice returned by OpenRouter." }, cancellationToken); - return default; - } - - return message.Clone(); -} - -static bool TryReadToolCall(JsonElement toolCall, out string id, out string name, out string arguments) -{ - id = string.Empty; - name = string.Empty; - arguments = "{}"; - - if (!toolCall.TryGetProperty("id", out JsonElement idElement) - || !toolCall.TryGetProperty("function", out JsonElement functionElement) - || !functionElement.TryGetProperty("name", out JsonElement nameElement)) - { - return false; - } - - id = idElement.GetString() ?? string.Empty; - name = nameElement.GetString() ?? string.Empty; - arguments = functionElement.TryGetProperty("arguments", out JsonElement argsElement) - ? argsElement.GetString() ?? "{}" - : "{}"; - return true; -} - -static bool TryGetFirstChoiceMessage(JsonElement root, out JsonElement message) -{ - message = default; - if (!root.TryGetProperty("choices", out JsonElement choices) - || choices.ValueKind != JsonValueKind.Array - || choices.GetArrayLength() == 0) - { - return false; - } - - JsonElement first = choices[0]; - return first.TryGetProperty("message", out message); -} - -static Dictionary ParseArguments(string raw) -{ - try - { - using JsonDocument doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(raw) ? "{}" : raw); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return new Dictionary(); - - Dictionary parsed = new(); - foreach (JsonProperty property in doc.RootElement.EnumerateObject()) - parsed[property.Name] = ConvertJsonElement(property.Value); - return parsed; - } - catch - { - return new Dictionary(); - } -} - -static string? ReadOptionalStringArgument(Dictionary arguments, string key) -{ - if (!arguments.TryGetValue(key, out object? value) || value is null) - return null; - - return value switch - { - string text => text.Trim(), - _ => Convert.ToString(value)?.Trim() - }; -} - -static object? ConvertJsonElement(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(ConvertJsonElement).ToArray(), - JsonValueKind.Object => element.EnumerateObject().ToDictionary(prop => prop.Name, prop => ConvertJsonElement(prop.Value)), - _ => element.GetRawText() - }; -} - -static string ReadToolResultText(CallToolResult result) -{ - if (result.Content is null) - return result.IsError == true ? "{\"success\":false}" : "{\"success\":true}"; - - StringBuilder sb = new(); - foreach (ContentBlock block in result.Content) - { - if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text)) - { - if (sb.Length > 0) - sb.Append('\n'); - sb.Append(text.Text); - } - } - - if (sb.Length > 0) - return sb.ToString(); - - return JsonSerializer.Serialize(new { isError = result.IsError }); -} - -static bool InferStructuredToolError(string toolResultText) -{ - try - { - using JsonDocument doc = JsonDocument.Parse(toolResultText); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return false; - - if (doc.RootElement.TryGetProperty("success", out JsonElement successElement) - && successElement.ValueKind == JsonValueKind.False) - { - return true; - } - - return doc.RootElement.TryGetProperty("errorCode", out JsonElement errorCodeElement) - && errorCodeElement.ValueKind == JsonValueKind.String - && !string.IsNullOrWhiteSpace(errorCodeElement.GetString()); - } - catch - { - return false; - } -} - -static bool TryNormalizeTodoStatus(string rawStatus, out string normalizedStatus) -{ - normalizedStatus = rawStatus.Trim().ToLowerInvariant(); - return normalizedStatus is "pending" or "in_progress" or "completed" or "blocked" or "cancelled"; -} - -static string[] GetTodoStatusValues() -{ - return ["pending", "in_progress", "completed", "blocked", "cancelled"]; -} - -static object ToTodoDto(TodoEntry item) -{ - return new - { - id = item.Id, - content = item.Content, - status = item.Status, - notes = item.Notes, - order = item.Order - }; -} - -static string ParseAgentFinishAnswer(string argumentsRaw) -{ - try - { - using JsonDocument doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsRaw) ? "{}" : argumentsRaw); - if (doc.RootElement.TryGetProperty("answer", out JsonElement answerElement) - && answerElement.ValueKind == JsonValueKind.String) - { - string answer = answerElement.GetString() ?? string.Empty; - if (!string.IsNullOrWhiteSpace(answer)) - return answer.Trim(); - } - } - catch - { - // ignore and use fallback below - } - - return """ -Reasoning: -- The model requested completion without a textual payload. -- Returning a safe fallback response. - -Answer: -I completed the requested tool workflow but did not receive a final textual answer payload. -"""; -} - -static string EnsureFinalAnswerFormat(string text, IReadOnlyList observations) -{ - string trimmed = text.Trim(); - if (trimmed.Length == 0) - trimmed = "I completed the tool workflow but produced no textual output."; - - bool hasReasoning = trimmed.Contains("Reasoning:", StringComparison.OrdinalIgnoreCase); - bool hasAnswer = trimmed.Contains("Answer:", StringComparison.OrdinalIgnoreCase); - if (hasReasoning && hasAnswer) - return trimmed; - - string[] latestObservations = observations - .TakeLast(3) - .ToArray(); - if (latestObservations.Length == 0) - latestObservations = ["Tool-assisted reasoning completed."]; - - string observationBullets = string.Join('\n', latestObservations.Select(observation => $"- {observation}")); - return $""" -Reasoning: -{observationBullets} -- Final response generated after tool execution and verification. - -Answer: -{trimmed} -"""; -} - -static bool ShouldInjectReminder(int iteration, int maxIterations, int toolCallCount, int maxToolCalls, TimeSpan elapsed, TimeSpan maxWallTime) -{ - return iteration >= maxIterations - 6 - || toolCallCount >= maxToolCalls - 12 - || elapsed >= maxWallTime - TimeSpan.FromSeconds(45); -} - -static string BuildForcedFinalAnswer( - IReadOnlyList observations, - int toolCalls, - TimeSpan elapsed, - int maxIterations, - int maxToolCalls, - TimeSpan maxWallTime) -{ - string lastObservation = observations.Count > 0 ? observations[^1] : "No tool observation was captured."; - return $""" -Reasoning: -- The agent loop reached its safety budget before `agent_finish` was called. -- Last observation: {lastObservation} -- Budget usage: toolCalls={toolCalls}/{maxToolCalls}, elapsed={elapsed.TotalSeconds:F1}s/{maxWallTime.TotalSeconds:F1}s, maxIterations={maxIterations}. - -Answer: -I could not complete this request within the configured tool budget. Ask me to retry and I will continue with a fresh loop. -"""; -} - -static string SummarizeObservation(string toolName, string toolResultText, bool isError) -{ - string status = isError ? "error" : "ok"; - return $"{toolName} => {status}: {Truncate(toolResultText.Replace('\n', ' '), 180)}"; -} - -static string Truncate(string text, int maxLength) -{ - if (string.IsNullOrEmpty(text) || text.Length <= maxLength) - return text; - return text[..maxLength] + "..."; -} - -static async Task StreamFinalAnswer(HttpResponse response, string finalText, CancellationToken cancellationToken) -{ - string text = finalText.Trim(); - if (text.Length == 0) - text = "I completed the request but no final text was generated."; - - MatchCollection tokens = Regex.Matches(text, @"\S+\s*", RegexOptions.CultureInvariant); - if (tokens.Count == 0) - { - await WriteEvent(response, "token", new { text }, cancellationToken); - await WriteEvent(response, "final", new { text }, cancellationToken); - return; - } - - const int wordsPerChunk = 10; - StringBuilder chunk = new(); - int words = 0; - - foreach (Match token in tokens.Cast()) - { - chunk.Append(token.Value); - words++; - if (words >= wordsPerChunk) - { - await WriteEvent(response, "token", new { text = chunk.ToString() }, cancellationToken); - chunk.Clear(); - words = 0; - } - } - - if (chunk.Length > 0) - await WriteEvent(response, "token", new { text = chunk.ToString() }, cancellationToken); - - await WriteEvent(response, "final", new { text }, cancellationToken); -} - -static int GetBoundedInt(string envName, int fallback, int min, int max) -{ - string? raw = Environment.GetEnvironmentVariable(envName); - if (!int.TryParse(raw, out int parsed)) - return fallback; - return Math.Clamp(parsed, min, max); -} - -static async Task WriteEvent(HttpResponse response, string eventName, object payload, CancellationToken cancellationToken) -{ - string json = JsonSerializer.Serialize(payload); - await response.WriteAsync($"event: {eventName}\n", cancellationToken); - await response.WriteAsync($"data: {json}\n\n", cancellationToken); - await response.Body.FlushAsync(cancellationToken); -} - -public sealed class ChatStreamRequest -{ - public List? Messages { get; set; } -} - -public sealed class ChatMessage -{ - public string Role { get; set; } = string.Empty; - public string Content { get; set; } = string.Empty; -} - -public sealed class TodoEntry -{ - public required string Id { get; init; } - public required int Order { get; init; } - public string Content { get; set; } = string.Empty; - public string Status { get; set; } = "pending"; - public string? Notes { get; set; } -} diff --git a/DebugTools/MccMcpWebPlayground/appsettings.Development.json b/DebugTools/MccMcpWebPlayground/appsettings.Development.json index 0c208ae9..6cde4d27 100644 --- a/DebugTools/MccMcpWebPlayground/appsettings.Development.json +++ b/DebugTools/MccMcpWebPlayground/appsettings.Development.json @@ -4,5 +4,11 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "MccWebHarness": { + "AllowFallbacks": false, + "DisableParallelToolCalls": true, + "ExposeInventoryWindowAction": false, + "ExposeInternalCommandTool": false } } diff --git a/DebugTools/MccMcpWebPlayground/appsettings.json b/DebugTools/MccMcpWebPlayground/appsettings.json index 10f68b8c..869684a5 100644 --- a/DebugTools/MccMcpWebPlayground/appsettings.json +++ b/DebugTools/MccMcpWebPlayground/appsettings.json @@ -5,5 +5,20 @@ "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": "*" } diff --git a/DebugTools/MccMcpWebPlayground/wwwroot/app.js b/DebugTools/MccMcpWebPlayground/wwwroot/app.js new file mode 100644 index 00000000..14cd9c0e --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/wwwroot/app.js @@ -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); +} diff --git a/DebugTools/MccMcpWebPlayground/wwwroot/index.html b/DebugTools/MccMcpWebPlayground/wwwroot/index.html index 68ae6c67..71a9d992 100644 --- a/DebugTools/MccMcpWebPlayground/wwwroot/index.html +++ b/DebugTools/MccMcpWebPlayground/wwwroot/index.html @@ -3,1115 +3,77 @@ - MCC MCP Live Playground + MCC MCP Playground - - + + - - - - -
-
+
+
-

MCC MCP Playground

+
+
MCC MCP Playground
+
Canonical guidance bootstrap, typed run state, verified completion
+
-
-
Booting…
-
- -
- -
+
+
- Chat +

Conversation

- +
-
-
- - - -

No messages yet.
Ask the LLM to control MCC via MCP.

+
+
+

No messages yet.

+

Ask the harness to inspect or act through MCC's MCP server.

-
- Thinking -
- -
+
+
- -
+
- - - - - Tool Events - +

Run Timeline

- - +
-
-
- - - -

No tool events yet.

+
+
+

No run events yet.

+

Typed SSE events will appear here as the harness runs.

- - -
- -