readFileTool function
- ExecutionEnv env, {
- HashlineSnapshotStore? snapshots,
- Model? model()?,
- SqliteEngine? sqlite,
- String? builtinText(
- 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.tarand.tar.gz/.tgz, viaarchive_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 …, viasqlite/sqlite_reader.dart): rendered as width-capped ASCII tables. Only available when the host provides a SqliteEngine (FFI, exported fromlib/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,
);
},
);
}