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
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue