package ar.com.companeros.tools; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; /** Biblioteca certificada y propuestas de herramientas dentro de config/companeros/Tools. */ public final class ToolRegistry { private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create(); private static final int MAX_PROPOSALS = 256; private static final int MAX_DEPENDENCY_DEPTH = 4; private final Path directory; private final Map certified = new LinkedHashMap<>(); private final Map pending = new LinkedHashMap<>(); private final Map primitives = new LinkedHashMap<>(); private final Map statistics = new HashMap<>(); private boolean statisticsDirty; public ToolRegistry(Path toolsDirectory) throws IOException { directory = toolsDirectory.toAbsolutePath().normalize(); if (Files.isSymbolicLink(directory)) throw new IOException("Tools no puede ser un enlace simbólico"); Files.createDirectories(directory); installMinecraftPrimitives(); loadStatistics(); } // El registro de capacidades pertenece al código del mod, nunca a los archivos propuestos. public synchronized void registerPrimitive(String name, ToolPermission permission, Set allowedArguments) { ToolManifest.identifier(name); for (String argument : allowedArguments) ToolManifest.argumentName(argument); if (primitives.containsKey(name)) throw ToolManifest.invalid("Primitiva ya registrada"); primitives.put(name, new PrimitiveDefinition(java.util.Objects.requireNonNull(permission), Set.copyOf(allowedArguments))); } public synchronized void registerCertified(ToolManifest manifest) { Key key = new Key(manifest.id(), manifest.version()); ToolManifest existing = certified.get(key); if (existing != null) { if (!existing.sha256().equals(manifest.sha256())) throw ToolManifest.invalid("Una versión certificada es inmutable"); return; } validateDefinition(manifest, new HashSet<>(), 0, new int[] { 0 }); certified.put(key, manifest); } /** Solo un controlador confiable llama esta promoción después de probar el hash exacto. */ public synchronized ToolManifest certify(String proposalHash, UUID trustedReviewer, TestEvidence evidence) { Proposal proposal = pending.get(proposalHash); if (proposal == null) throw ToolManifest.invalid("Propuesta desconocida"); if (trustedReviewer == null || evidence == null || !evidence.passed() || !proposalHash.equals(evidence.manifestHash()) || evidence.suite().isBlank() || evidence.suite().length() > 160 || evidence.completedAt() == null || evidence.completedAt().isAfter(Instant.now().plusSeconds(60))) throw ToolManifest.invalid("La certificación requiere evidencia aprobada del hash exacto"); registerCertified(proposal.manifest()); pending.remove(proposalHash); return proposal.manifest(); } // Los nombres de archivo se construyen con identificadores validados; el JSON no elige rutas. public synchronized Proposal propose(UUID trustedAuthor, String source) throws IOException { if (pending.size() >= MAX_PROPOSALS) throw ToolManifest.invalid("Límite de propuestas pendientes alcanzado"); ToolManifest manifest = ToolManifest.parse(source, trustedAuthor); validateDefinition(manifest, new HashSet<>(), 0, new int[] { 0 }); Proposal old = pending.get(manifest.sha256()); if (old != null) return old; Proposal proposal = new Proposal(manifest, Instant.now()); JsonObject envelope = new JsonObject(); envelope.addProperty("status", "pending"); envelope.addProperty("author", trustedAuthor.toString()); envelope.addProperty("createdAt", proposal.createdAt().toString()); envelope.addProperty("sha256", manifest.sha256()); envelope.add("manifest", manifest.toJson()); String name = "pending_" + manifest.id() + "_v" + manifest.version() + "_" + manifest.sha256().substring(0, 16) + ".json"; writeAtomically(directory.resolve(name), JSON.toJson(envelope)); pending.put(manifest.sha256(), proposal); return proposal; } /** Recupera candidatos. Ningún archivo se convierte automáticamente en herramienta ejecutable. */ public synchronized LoadReport loadProposals() throws IOException { List rejected = new ArrayList<>(); int loaded = 0; List files; try (var paths = Files.list(directory)) { files = paths.filter(path -> path.getFileName().toString().startsWith("pending_") && path.getFileName().toString().endsWith(".json")).sorted().limit(MAX_PROPOSALS + 1L).toList(); } if (files.size() > MAX_PROPOSALS) rejected.add("Cantidad de propuestas superior al límite"); for (Path file : files.stream().limit(MAX_PROPOSALS).toList()) { try { if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > ToolManifest.MAX_SOURCE_BYTES + 4096L) throw ToolManifest.invalid("Archivo inválido o demasiado grande"); JsonElement parsed = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)); if (!parsed.isJsonObject()) throw ToolManifest.invalid("Sobre inválido"); JsonObject envelope = parsed.getAsJsonObject(); ToolManifest.rejectUnknown(envelope, Set.of("status", "author", "createdAt", "sha256", "manifest")); if (!"pending".equals(envelope.get("status").getAsString())) throw ToolManifest.invalid("El archivo no puede conceder aprobación"); UUID author = UUID.fromString(envelope.get("author").getAsString()); ToolManifest manifest = ToolManifest.parse(envelope.get("manifest").toString(), author); if (!manifest.sha256().equals(envelope.get("sha256").getAsString())) throw ToolManifest.invalid("El contenido no coincide con su hash"); ToolManifest approved = certified.get(new Key(manifest.id(), manifest.version())); if (approved != null) { if (!approved.sha256().equals(manifest.sha256())) throw ToolManifest.invalid("Versión certificada adulterada"); continue; // Un sobre pendiente antiguo no revoca una certificación comprobada. } validateDefinition(manifest, new HashSet<>(), 0, new int[] { 0 }); if (pending.size() >= MAX_PROPOSALS && !pending.containsKey(manifest.sha256())) throw ToolManifest.invalid("Límite de propuestas alcanzado"); pending.put(manifest.sha256(), new Proposal(manifest, Instant.parse(envelope.get("createdAt").getAsString()))); loaded++; } catch (RuntimeException exception) { rejected.add(file.getFileName() + ": " + safeMessage(exception)); } catch (StackOverflowError error) { rejected.add(file.getFileName() + ": JSON demasiado profundo"); } } return new LoadReport(loaded, List.copyOf(rejected)); } // Preparación: permisos, dependencias y argumentos se comprueban antes de iniciar acciones. public synchronized ToolExecution start(String id, int version, ExecutionContext context, PrimitiveExecutor executor) { ToolManifest manifest = certified.get(new Key(ToolManifest.identifier(id), version)); if (manifest == null) throw ToolManifest.invalid("Herramienta no certificada"); return prepare(manifest, context, executor, status -> recordResult(new Key(manifest.id(), manifest.version()), status)); } // Sólo el servicio local de aprendizaje abre pruebas pendientes; el JSON no concede este permiso. synchronized ToolExecution startTrial(ToolManifest manifest, ExecutionContext context, PrimitiveExecutor executor, java.util.function.Consumer completed) { Proposal proposal = pending.get(manifest.sha256()); if (proposal == null || !proposal.manifest().author().equals(context.actorId())) throw ToolManifest.invalid("La prueba pertenece al autor de una propuesta pendiente"); return prepare(manifest, context, executor, status -> { recordResult(new Key(manifest.id(), manifest.version()), status); completed.accept(status); }); } synchronized void validateInputs(ToolManifest manifest, ExecutionContext context) { compile(manifest, context.inputs(), context.permissions(), new ArrayList<>(), new ArrayList<>(), 0, manifest.maxTicks()); } Path directory() { return directory; } private ToolExecution prepare(ToolManifest manifest, ExecutionContext context, PrimitiveExecutor executor, java.util.function.Consumer completed) { if (context == null || executor == null) throw ToolManifest.invalid("Falta el contexto de ejecución"); if (!context.permissions().containsAll(manifest.permissions())) throw ToolManifest.invalid("Permisos insuficientes"); List steps = new ArrayList<>(); List budgets = new ArrayList<>(); compile(manifest, context.inputs(), context.permissions(), steps, budgets, 0, manifest.maxTicks()); if (steps.size() > manifest.maxSteps()) throw ToolManifest.invalid("La composición excede su límite de pasos"); return new ToolExecution(manifest, context, List.copyOf(steps), List.copyOf(budgets), executor, completed); } public synchronized List certifiedTools() { return List.copyOf(certified.values()); } public synchronized List pendingProposals() { return List.copyOf(pending.values()); } public synchronized Statistics statistics(String id, int version) { return statistics.getOrDefault(new Key(id, version), new Statistics(0, 0, 0, "")); } public synchronized void saveStatistics() throws IOException { if (!statisticsDirty) return; JsonObject document = new JsonObject(); document.addProperty("schema", 1); JsonObject entries = new JsonObject(); for (var entry : statistics.entrySet()) { JsonObject value = new JsonObject(); value.addProperty("successes", entry.getValue().successes()); value.addProperty("failures", entry.getValue().failures()); value.addProperty("cancellations", entry.getValue().cancellations()); value.addProperty("lastResult", entry.getValue().lastResult()); entries.add(entry.getKey().id() + ":" + entry.getKey().version(), value); } document.add("tools", entries); writeAtomically(directory.resolve("statistics.json"), JSON.toJson(document)); statisticsDirty = false; } private synchronized void recordResult(Key key, ToolExecution.Status status) { Statistics old = statistics.getOrDefault(key, new Statistics(0, 0, 0, "")); statistics.put(key, new Statistics(increment(old.successes(), status == ToolExecution.Status.SUCCEEDED), increment(old.failures(), status == ToolExecution.Status.FAILED), increment(old.cancellations(), status == ToolExecution.Status.CANCELLED), Instant.now().toString())); statisticsDirty = true; } private void validateDefinition(ToolManifest manifest, Set ancestors, int depth, int[] visits) { if (depth > MAX_DEPENDENCY_DEPTH) throw ToolManifest.invalid("Dependencias demasiado profundas"); if (++visits[0] > 512) throw ToolManifest.invalid("Grafo de dependencias demasiado grande"); Key key = new Key(manifest.id(), manifest.version()); if (!ancestors.add(key)) throw ToolManifest.invalid("Dependencia circular"); try { for (ToolManifest.Dependency dependency : manifest.dependencies()) { ToolManifest child = exactDependency(dependency); if (!manifest.permissions().containsAll(child.permissions())) throw ToolManifest.invalid("Faltan permisos de una dependencia"); validateDefinition(child, ancestors, depth + 1, visits); } for (ToolManifest.Step step : manifest.steps()) { if (step.primitive() == null) continue; PrimitiveDefinition definition = primitives.get(step.primitive()); if (definition == null) throw ToolManifest.invalid("Primitiva no permitida: " + step.primitive()); if (!manifest.permissions().contains(definition.permission())) throw ToolManifest.invalid("Falta un permiso declarado"); validatePrimitiveArguments(step.primitive(), step.arguments(), definition, true); } } finally { ancestors.remove(key); } } private ToolManifest exactDependency(ToolManifest.Dependency dependency) { ToolManifest child = certified.get(new Key(dependency.id(), dependency.version())); if (child == null || !child.sha256().equals(dependency.sha256())) throw ToolManifest.invalid("Dependencia no certificada o hash diferente"); return child; } private void compile(ToolManifest manifest, JsonObject inputs, Set permissions, List result, List budgets, int depth, int maximumTicks) { if (depth > MAX_DEPENDENCY_DEPTH) throw ToolManifest.invalid("Composición demasiado profunda"); if (!permissions.containsAll(manifest.permissions())) throw ToolManifest.invalid("Permisos insuficientes para una dependencia"); int start = result.size(); for (ToolManifest.Step step : manifest.steps()) { JsonObject args = ToolManifest.resolve(step.arguments(), inputs); if (step.primitive() != null) { PrimitiveDefinition definition = primitives.get(step.primitive()); validatePrimitiveArguments(step.primitive(), args, definition, false); result.add(new CompiledStep(step.primitive(), args, step.timeoutTicks())); } else { ToolManifest child = exactDependency(step.tool()); compile(child, args, permissions, result, budgets, depth + 1, Math.min(step.timeoutTicks(), child.maxTicks())); } if (result.size() > ToolManifest.MAX_STEPS) throw ToolManifest.invalid("Demasiados pasos expandidos"); } if (result.size() - start > manifest.maxSteps()) throw ToolManifest.invalid("La dependencia excede su límite de pasos"); budgets.add(new ExecutionBudget(start, result.size(), Math.min(maximumTicks, manifest.maxTicks()))); } private void installMinecraftPrimitives() { registerPrimitive("move_to", ToolPermission.MOVE, Set.of("x", "y", "z")); registerPrimitive("mine_block", ToolPermission.MINE, Set.of("x", "y", "z")); registerPrimitive("place_block", ToolPermission.PLACE, Set.of("x", "y", "z", "item")); registerPrimitive("craft", ToolPermission.CRAFT, Set.of("item", "count")); registerPrimitive("eat", ToolPermission.EAT, Set.of()); registerPrimitive("attack", ToolPermission.COMBAT, Set.of("target")); registerPrimitive("collect", ToolPermission.INVENTORY, Set.of("radius")); registerPrimitive("store", ToolPermission.INVENTORY, Set.of("x", "y", "z", "item", "count")); registerPrimitive("inspect", ToolPermission.WORLD_READ, Set.of()); registerPrimitive("research_recipe", ToolPermission.WORLD_READ, Set.of("item")); registerPrimitive("research_web", ToolPermission.WORLD_READ, Set.of("query")); registerPrimitive("choose_skin", ToolPermission.WORLD_READ, Set.of("profile")); registerPrimitive("parasite_upgrade_propose", ToolPermission.WORLD_READ, Set.of("capacity", "goal")); registerPrimitive("parasite_upgrade_status", ToolPermission.WORLD_READ, Set.of()); registerPrimitive("parasite_upgrade_catalog", ToolPermission.WORLD_READ, Set.of("query")); registerPrimitive("parasite_upgrade_apply", ToolPermission.PARASITE_UPGRADE, Set.of("quote")); registerPrimitive("parasite_appearance", ToolPermission.PARASITE_UPGRADE, Set.of("prefab", "slot")); registerPrimitive("parasite_appearance_catalog", ToolPermission.WORLD_READ, Set.of()); registerPrimitive("propose_tool", ToolPermission.WORLD_READ, Set.of("manifest")); registerPrimitive("suggest_change", ToolPermission.SUGGESTION_WRITE, Set.of("manifest")); registerPrimitive("sneak", ToolPermission.MOVE, Set.of("enabled")); registerPrimitive("create_banner", ToolPermission.CRAFT, Set.of("baseColor", "circleColor", "symbol", "symbolColor")); registerPrimitive("place_banner", ToolPermission.PLACE, Set.of("x", "y", "z")); registerPrimitive("message_internal", ToolPermission.INTERNAL_CHAT, Set.of("recipient", "text")); registerPrimitive("message_global", ToolPermission.GLOBAL_CHAT, Set.of("text")); } private static void validatePrimitiveArguments(String primitive, JsonObject args, PrimitiveDefinition definition, boolean referencesAllowed) { if (definition == null) throw ToolManifest.invalid("Primitiva desconocida"); ToolManifest.rejectUnknown(args, definition.arguments()); ToolManifest.validateArguments(args, referencesAllowed); Set required = switch (primitive) { case "move_to", "mine_block", "place_banner" -> Set.of("x", "y", "z"); case "place_block", "store" -> Set.of("x", "y", "z", "item"); case "craft", "research_recipe" -> Set.of("item"); case "research_web" -> Set.of("query"); case "choose_skin" -> Set.of("profile"); case "parasite_upgrade_propose" -> Set.of("capacity", "goal"); case "parasite_upgrade_catalog" -> Set.of("query"); case "parasite_upgrade_apply" -> Set.of("quote"); case "parasite_appearance" -> Set.of("prefab", "slot"); case "propose_tool", "suggest_change" -> Set.of("manifest"); case "sneak" -> Set.of("enabled"); case "create_banner" -> Set.of("baseColor", "circleColor", "symbol", "symbolColor"); case "attack" -> Set.of("target"); case "message_internal" -> Set.of("recipient", "text"); case "message_global" -> Set.of("text"); default -> Set.of(); }; if (!args.keySet().containsAll(required)) throw ToolManifest.invalid("Faltan argumentos de la primitiva " + primitive); for (var entry : args.entrySet()) { JsonElement value = entry.getValue(); if (referencesAllowed && value.isJsonObject()) continue; switch (entry.getKey()) { case "x", "y", "z", "count", "radius" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) throw ToolManifest.invalid("Se esperaba un número"); double number = value.getAsDouble(); if (!Double.isFinite(number) || Math.abs(number) > 30_000_000) throw ToolManifest.invalid("Coordenada fuera del límite"); if (entry.getKey().equals("count") && (number != Math.floor(number) || number < 1 || number > 64)) throw ToolManifest.invalid("Cantidad fuera del límite"); if (entry.getKey().equals("radius") && (number <= 0 || number > 4)) throw ToolManifest.invalid("Alcance de recogida fuera del límite"); if (!primitive.equals("move_to") && Set.of("x", "y", "z").contains(entry.getKey()) && number != Math.floor(number)) throw ToolManifest.invalid("La posición del bloque debe usar enteros"); } case "item" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || !value.getAsString().matches("[a-z0-9_.-]+:[a-z0-9_./-]+")) throw ToolManifest.invalid("Identificador de item inválido"); } case "profile" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Cuenta de skin inválida"); ar.com.companeros.identity.SkinSources.profile(value.getAsString()); } case "capacity" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Capacidad inválida"); try { ar.com.companeros.horror.ParasiteUpgrades.capacity(value.getAsString()); } catch (IllegalArgumentException failure) { throw ToolManifest.invalid("Capacidad inválida"); } } case "goal" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank() || value.getAsString().length() > 220 || value.getAsString().contains("\n")) throw ToolManifest.invalid("Objetivo inválido"); } case "quote" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || !canonicalUuid(value.getAsString())) throw ToolManifest.invalid("Cotización inválida"); } case "target" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Identidad inválida"); try { UUID.fromString(value.getAsString()); } catch (IllegalArgumentException exception) { throw ToolManifest.invalid("Identidad inválida"); } } case "recipient" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Destinatario inválido"); String recipient = value.getAsString(); if (!recipient.equalsIgnoreCase("all") && !recipient.matches("[A-Za-z0-9_]{1,16}") && !canonicalUuid(recipient)) throw ToolManifest.invalid("El destinatario debe ser all, un nombre o UUID"); } case "text", "query" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) throw ToolManifest.invalid("Mensaje vacío o inválido"); if (entry.getKey().equals("query") && (value.getAsString().length() > 220 || value.getAsString().contains("\n"))) throw ToolManifest.invalid("Consulta web fuera de límite"); } case "manifest" -> { if (!Set.of("propose_tool", "suggest_change").contains(primitive) || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank() || value.getAsString().getBytes(StandardCharsets.UTF_8).length > 4096) throw ToolManifest.invalid("Propuesta de herramienta fuera del límite"); } case "enabled" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) throw ToolManifest.invalid("Se requiere true o false"); } case "baseColor", "circleColor", "symbolColor" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || java.util.Arrays.stream(net.minecraft.world.item.DyeColor.values()) .noneMatch(color -> color.getName().equals(value.getAsString()))) throw ToolManifest.invalid("Color de bandera desconocido"); } case "symbol" -> { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw ToolManifest.invalid("Símbolo de bandera inválido"); ToolManifest.identifier(value.getAsString()); } default -> { } // Una extensión confiable puede definir otros argumentos escalares. } } } private static boolean canonicalUuid(String value) { try { return UUID.fromString(value).toString().equalsIgnoreCase(value); } catch (IllegalArgumentException exception) { return false; } } // Estadísticas persistentes: escritura explícita en checkpoint, nunca en cada tick. private void loadStatistics() throws IOException { Path file = directory.resolve("statistics.json"); if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return; if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > 1_048_576) throw new IOException("Archivo de estadísticas inválido"); try { JsonObject root = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject(); if (root.get("schema").getAsInt() != 1) throw ToolManifest.invalid("Estadísticas de versión desconocida"); JsonObject entries = root.getAsJsonObject("tools"); if (entries.size() > 4096) throw ToolManifest.invalid("Demasiadas estadísticas"); for (var entry : entries.entrySet()) { String[] parts = entry.getKey().split(":", -1); if (parts.length != 2) throw ToolManifest.invalid("Clave de estadística inválida"); int version = Integer.parseInt(parts[1]); if (version < 1 || version > 1_000_000) throw ToolManifest.invalid("Versión inválida"); JsonObject value = entry.getValue().getAsJsonObject(); statistics.put(new Key(ToolManifest.identifier(parts[0]), version), new Statistics( nonnegative(value, "successes"), nonnegative(value, "failures"), nonnegative(value, "cancellations"), value.get("lastResult").getAsString())); } } catch (RuntimeException exception) { throw new IOException("Estadísticas dañadas", exception); } } private static long nonnegative(JsonObject value, String key) { long number = value.get(key).getAsBigDecimal().longValueExact(); if (number < 0) throw ToolManifest.invalid("Estadística negativa"); return number; } private static long increment(long value, boolean increment) { return increment && value < Long.MAX_VALUE ? value + 1 : value; } private void writeAtomically(Path target, String source) throws IOException { Path checked = target.toAbsolutePath().normalize(); if (!checked.getParent().equals(directory) || Files.isSymbolicLink(checked)) throw new IOException("Ruta fuera de Tools"); Path temporary = directory.resolve("tool_write_" + UUID.randomUUID() + ".tmp"); try { Files.writeString(temporary, source, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); try { Files.move(temporary, checked, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (java.nio.file.AtomicMoveNotSupportedException exception) { Files.move(temporary, checked, StandardCopyOption.REPLACE_EXISTING); } } finally { Files.deleteIfExists(temporary); } } private static String safeMessage(RuntimeException exception) { String message = exception.getMessage(); return message == null ? "Formato inválido" : message.substring(0, Math.min(message.length(), 256)); } private record Key(String id, int version) { } private record PrimitiveDefinition(ToolPermission permission, Set arguments) { } public record Proposal(ToolManifest manifest, Instant createdAt) { } public record LoadReport(int loaded, List rejected) { } public record TestEvidence(String manifestHash, String suite, boolean passed, Instant completedAt) { } public record Statistics(long successes, long failures, long cancellations, String lastResult) { public double successRate() { double tested = (double) successes + failures; return tested == 0 ? 0 : successes / tested; } } public record ExecutionContext(UUID actorId, long startTick, Set permissions, JsonObject inputs) { public ExecutionContext { if (actorId == null || startTick < 0 || permissions == null || inputs == null) throw ToolManifest.invalid("Contexto inválido"); permissions = Set.copyOf(permissions); inputs = inputs.deepCopy(); ToolManifest.validateArguments(inputs, false); } @Override public JsonObject inputs() { return inputs.deepCopy(); } } static record CompiledStep(String primitive, JsonObject arguments, int timeoutTicks) { } static record ExecutionBudget(int startStep, int endStep, int maxTicks) { } }