readFileTool function

AgentTool readFileTool(
  1. ExecutionEnv env, {
  2. HashlineSnapshotStore? snapshots,
  3. Model? model()?,
  4. SqliteEngine? sqlite,
  5. String? builtinText(
    1. String path
    )?,
})

Creates the read tool: reads a text file or image with optional offset (1-indexed) and limit, truncating text output to defaultToolMaxLines lines or defaultToolMaxBytes bytes with an actionable continuation notice. Images are decoded, optionally resized to the inline dimension/byte limits, and returned as base64 content.

The path may carry a trailing selector (oh-my-pi's grammar, ported in read_selector.dart): :N / :A-B / :A+C line ranges, comma-merged multi-ranges (:5-16,960-973), and :raw verbatim output — alone or combined with a range in either order (:raw:50-100). A single range maps onto the offset/limit pipeline; multi-ranges render one block per range joined by an elision separator, with out-of-bounds ranges reported as skipped notices. :raw suppresses line numbers, the hashline header, and all continuation notices. The offset/limit arguments keep working and must not be combined with a selector.

Two extended targets consume their own colon syntax behind the same tool:

  • Archive inner paths (archive.zip:inner/entry, also .tar and .tar.gz/.tgz, via archive_reader.dart): the member's text runs through the same pipeline (selectors apply after extraction); a bare archive path or inner directory lists its contents, and binary entries yield a note instead of bytes.
  • SQLite databases (data.db, data.db:table, data.db:table:key, data.db:table?limit=…&offset=…&order=…&where=…, data.db?q=SELECT …, via sqlite/sqlite_reader.dart): rendered as width-capped ASCII tables. Only available when the host provides a SqliteEngine (FFI, exported from lib/io.dart); without one the read returns a clean "not supported" note — what web hosts get. The tool description mirrors that gating: without an engine the SQLite section is omitted.

With hashline: true (omp's hashline display mode), text output lines are prefixed with their 1-indexed line number (N:text) and the output is preceded by a [path#TAG] header carrying the whole-file content-hash tag; the full file text plus the displayed line range are recorded in snapshots so edit patches can anchor against them. Ranged reads keep real file line numbers. Default is false (omp defaults it on; we keep the legacy plain output as the default so existing read consumers are unaffected — the edit tool description tells the model to opt in when it intends to edit by anchors).

When model is provided, image results carry an extra note when the current model has no image input (pi's getNonVisionImageNote); the image itself stays in the result — providers substitute an explicit placeholder at request time (see downgradeUnsupportedImages).

Built-in skill reads (issue #1151, extended by gh-1444): a builtin:// path resolves from the embedded copy before any filesystem access, and a failed read of a path whose sibling <path>.pointer exists follows the pointer — but only to a builtin://skills/<name>/SKILL.md target matching the pointer's own directory (followSkillPointer); any other pointer target is refused loudly. A builtin resource that resolves EMPTY is retried once and then fails with an explicit error naming the resource (gh-1444 AC2/E5) — never a silent empty success. builtinText overrides the embedded-text lookup (the AC2 fault-injection seam for tests); production callers omit it.

Implementation

AgentTool readFileTool(
  ExecutionEnv env, {
  HashlineSnapshotStore? snapshots,
  Model? Function()? model,
  SqliteEngine? sqlite,
  String? Function(String path)? builtinText,
}) {
  final store = snapshots ?? HashlineSnapshotStore();
  return AgentTool(
    name: 'read',
    label: 'read',
    tier: ApprovalTier.read,
    description: _readDescription(sqlite: sqlite),
    parameters: const {
      'type': 'object',
      'properties': {
        'path': {
          'type': 'string',
          'description':
              'Path to the file to read (relative or absolute). May end '
              'with a trailing selector such as :50-100 or :raw, address an '
              'archive member (archive.zip:inner/file), or address a SQLite '
              'database (data.db:table?limit=20)',
        },
        'offset': {
          'type': 'integer',
          'description': 'Line number to start reading from (1-indexed)',
        },
        'limit': {
          'type': 'integer',
          'description': 'Maximum number of lines to read',
        },
        'hashline': {
          'type': 'boolean',
          'description':
              'Prefix each line with its line number and prepend a '
              '[path#TAG] content-hash header for anchoring hashline edit '
              'patches (default: false)',
        },
      },
      'required': ['path'],
    },
    execute: (arguments, cancelToken, onUpdate) async {
      cancelToken?.throwIfCancelled();
      final rawPath = arguments['path'] as String;
      final offset = (arguments['offset'] as num?)?.toInt();
      final limit = (arguments['limit'] as num?)?.toInt();
      final hashlineMode = (arguments['hashline'] as bool?) ?? false;

      // Peel a trailing selector off the path (omp's grammar). A literal file
      // whose name ends in a selector-shaped tail (`test:1-2`) wins over the
      // selector interpretation.
      final split = await splitPathAndSelPreferringLiteral(rawPath, env);
      final parsed = parseSel(split.sel);
      // Issue #862: a selector already pins the window, so offset/limit are
      // ignored with a notice instead of hard-rejecting the call.
      final windowNotice = readWindowCoercionNotice(
        hasSelector: parsed is! ReadSelectorNone,
        offset: offset,
        limit: limit,
      );

      final extended = await _readExtendedTarget(
        env,
        rawPath,
        split,
        sqlite,
        cancelToken,
      );
      if (extended != null) return _withNotice(extended, windowNotice);

      final path = split.path;
      // Built-in skills (issue #1151): builtin:// paths resolve from the
      // embedded copy before any filesystem access. gh-1444 AC2: a builtin
      // resource that resolves EMPTY is retried once, then fails loudly
      // naming the resource — an empty payload on a non-empty resource is
      // never a silent success.
      var embedded = (builtinText ?? builtinSkillTextAt)(path);
      if (embedded != null && embedded.isEmpty) {
        embedded = (builtinText ?? builtinSkillTextAt)(path);
        if (embedded == null || embedded.isEmpty) {
          throw StateError(
            'read $path: builtin resource resolved empty after retry — '
            'the compiled-in skill content is missing; report this as a '
            'harness defect',
          );
        }
      }
      if (embedded == null) {
        final binaryRead = await env.readBinaryFile(path);
        if (binaryRead.isErr) {
          // gh-1444 AC1: a skill pointer sibling (<path>.pointer) resolves
          // the read onto the compiled-in builtin body — the seeded
          // `.fah/skills/<name>/SKILL.md.pointer` files become transparent.
          // A present-but-invalid pointer is refused loudly (E1); no
          // pointer keeps the original error.
          final followed = await followSkillPointer(
            path,
            (pointerPath) async =>
                (await env.readTextFile(pointerPath)).valueOrNull,
          );
          switch (followed) {
            case SkillPointerResolved(:final text):
              embedded = text;
            case SkillPointerRefused(:final reason):
              throw StateError(
                'read $path: skill pointer refused: $reason',
              );
            case SkillPointerAbsent():
              throw StateError('${binaryRead.errorOrNull}');
          }
        } else {
          final bytes = binaryRead.valueOrNull!;
          cancelToken?.throwIfCancelled();

          final imageResult = _readImageResult(path, bytes, parsed, model);
          if (imageResult != null) return _withNotice(imageResult, windowNotice);
        }
      }

      return _withNotice(
        await _readTextContent(
          env,
          store,
          path,
          parsed,
          offset,
          limit,
          embedded,
          hashlineMode,
          cancelToken,
        ),
        windowNotice,
      );
    },
  );
}