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