validate method

ValidationResult validate(
  1. ValidatorAction action,
  2. Observation observation,
  3. List<ToolDescriptor> tools
)

Validate action against tools and the live UI in observation.

Implementation

ValidationResult validate(
  ValidatorAction action,
  Observation observation,
  List<ToolDescriptor> tools,
) {
  // Pass 1: tool known?
  ToolDescriptor? tool;
  for (final t in tools) {
    if (t.name == action.tool) {
      tool = t;
      break;
    }
  }
  if (tool == null) {
    return ValidationReject(
      tool: action.tool,
      reason: 'unknown_tool',
      expected: tools.map((t) => t.name).toList(growable: false),
      got: action.tool,
      description: 'tool "${action.tool}" is not available this turn',
    );
  }

  // Pass 2: args validate against inputSchema?
  final schema = JsonSchema.create(
    tool.inputSchema,
    schemaVersion: SchemaVersion.draft7,
  );
  final result = schema.validate(action.args);
  if (!result.isValid) {
    final err = result.errors.first;
    final pointer = err.instancePath.isEmpty ? '/' : err.instancePath;
    return ValidationReject(
      tool: action.tool,
      reason: 'schema_invalid',
      pointer: pointer,
      description: err.message,
    );
  }

  // Pass 3: scenario-declared core.done reason form.
  final RegExp? donePattern = doneReasonPattern;
  if (donePattern != null && action.tool == _kCoreDoneTool) {
    final Object? rawReason = action.args['reason'];
    final String reason = rawReason is String ? rawReason : '';
    if (!donePattern.hasMatch(reason)) {
      return ValidationReject(
        tool: action.tool,
        reason: 'done_reason_mismatch',
        pointer: '/reason',
        expected: <String>[donePattern.pattern],
        got: reason,
        description:
            'core.done reason must match ${donePattern.pattern}; copy the '
            'index and tool from the row you actually observed, and keep '
            'acting until you have observed it',
      );
    }
  }

  // Pass 4: scenario-declared done evidence, read from the observation.
  final RegExp? evidencePattern = doneEvidencePattern;
  if (evidencePattern != null && action.tool == _kCoreDoneTool) {
    final List<({String text, RegExpMatch match})> rows =
        <({String text, RegExpMatch match})>[];
    for (final SemanticsNode node in observation.core.nodes.values) {
      for (final String text in <String>[node.label, node.value]) {
        if (text.isEmpty) continue;
        final RegExpMatch? match = evidencePattern.firstMatch(text);
        if (match != null) rows.add((text: text, match: match));
      }
    }
    if (rows.isEmpty) {
      return ValidationReject(
        tool: action.tool,
        reason: 'done_evidence_missing',
        expected: <String>[evidencePattern.pattern],
        description:
            'core.done needs a node whose label or value matches '
            '${evidencePattern.pattern}; the current observation carries '
            'none, so keep acting until that evidence is on screen',
      );
    }
    final Object? rawEvidenceReason = action.args['reason'];
    final String evidenceReason = rawEvidenceReason is String
        ? rawEvidenceReason
        : '';
    final bool quotesObservedRow = rows.any(
      (({String text, RegExpMatch match}) row) =>
          _reasonQuotesRow(evidenceReason, row.match),
    );
    if (!quotesObservedRow) {
      return ValidationReject(
        tool: action.tool,
        reason: 'done_evidence_mismatch',
        pointer: '/reason',
        expected: <String>[
          for (final ({String text, RegExpMatch match}) row in rows) row.text,
        ],
        got: evidenceReason,
        description:
            'core.done reason must quote a row that is actually observed; '
            'copy the index and tool from one of the listed rows',
      );
    }
  }

  // Pass 5: core-tool semantic-node check.
  if (!_coreNodeTools.contains(action.tool)) {
    // Extension tools and node-less core tools (system_back / wait /
    // done) need no semantic-node check.
    return const ValidationOk();
  }

  final argKeys = _nodeArgKeys[action.tool] ?? const <String>[];
  for (final argKey in argKeys) {
    final v = action.args[argKey];
    if (v is! int) {
      // Schema validation already enforced int-ness; defensive skip.
      continue;
    }
    final node = observation.core.nodes[v];
    if (node == null) {
      return ValidationReject(
        tool: action.tool,
        reason: 'node_not_found',
        pointer: '/$argKey',
        got: v,
        description: '$argKey=$v is not present in observation.core.nodes',
      );
    }
    if (node.state.contains('disabled')) {
      return ValidationReject(
        tool: action.tool,
        reason: 'node_disabled',
        pointer: '/$argKey',
        got: v,
        description: '$argKey=$v is disabled (state: ${node.state})',
      );
    }
  }
  return const ValidationOk();
}