Skip to content

symfonic.core.prompt.blocks.sources.file

file

FileBlockSource -- a prompt block read from a file on disk.

An IDENTITY.md committed next to the application is the strongest form a block's content can take: it is version-controlled, it is reviewable in a pull request, and it is still there when the datastore is down. This adapter serves exactly that -- one instance backs one file -- and nothing else. It touches no database, no network and no cache, on any path.

The revision is the content hash, never the mtime

:attr:BlockRevision.revision is sha256:<hex> over the block's content. The obvious alternative -- the file's modification time -- is wrong in both directions, and both directions are damaging:

  • mtime changes when content does not. git checkout, rsync -a onto a fresh host and a container rebuild all restamp mtime on byte-identical files. A revision derived from it would change on every redeploy, invalidating the prompt cache for content that did not move and re-billing the full prefix on the next turn.
  • mtime can fail to change when content does. Coarse filesystem timestamp granularity, a restored backup, or a write that preserves times leave the stamp untouched. A revision derived from it would go on advertising a block that has already changed underneath it, and a cache keyed on that revision would serve the stale text.

A content hash has neither failure mode: it is a pure function of the bytes that will actually reach the prompt. Two hosts that checked the same commit out in different weeks compute the same revision.

created_at is therefore left None rather than set to the mtime. The same untrustworthiness that disqualifies mtime as a revision disqualifies it as a recorded fact, and :class:~symfonic.core.prompt.blocks.types.BlockRevision exists to keep recorded facts distinguishable from framework filler.

What this source deliberately cannot do

  • No history. A plain file has one state -- the one on disk. The adapter therefore does not present list_revisions / load_revision, so isinstance(src, HistoryCapableBlockSource) is False and :func:~symfonic.core.prompt.blocks.validation.check_operator_editable rejects operator_editable=True against it at construction. Git may well hold that file's history; this adapter does not read git, and claiming a capability it has not implemented is exactly what the structural Protocols exist to prevent.
  • Not scope-aware. One file is one value for the whole deployment, so scope_aware is False and scope is accepted and ignored. Pairing it with a non-deployment block scope is a construction-time error in :func:~symfonic.core.prompt.blocks.validation.check_scope_pairing; it is not silently served as per-tenant content.
  • Single block. block_id is accepted (the Protocol has no overload that omits it) and ignored, because the path -- not the id -- selects the content. It is still carried into error messages so an unreadable file names the block it was serving.

Failures are typed, not raw OSError

A missing or unreadable file raises :class:BlockFileNotFoundError / :class:BlockFileUnavailableError, both of which derive from :class:~symfonic.core.protocols.StorageError. The resolver catches the source-failure family and applies the block's on_source_failure policy -- fail_closed for the authored tiers, omit for the learned ones. A bare FileNotFoundError escaping to the caller would bypass that policy entirely and turn a missing optional block into a failed turn.

MAX_BLOCK_FILE_BYTES module-attribute

MAX_BLOCK_FILE_BYTES = 1048576

The largest file this adapter will read into a prompt -- 1 MiB.

A prompt block is operator-authored text, not a general-purpose file store: an authored block that size is already implausible, and reading past it converts a config mistake (a deploy that swapped a small IDENTITY.md for a generated or log file) into unbounded memory growth on every single resolve, with the failure surfacing downstream as memory pressure or a provider context error rather than through the block's on_source_failure policy. Checked against the file's stat size before any read, so an oversized file never reaches read_text.

REVISION_ALGORITHM module-attribute

REVISION_ALGORITHM = 'sha256'

The digest naming the revision. Recorded in the revision id itself.

BlockFileNotFoundError

Bases: BlockFileUnavailableError, NotFoundError

The backing file does not exist.

Derives from both :class:BlockFileUnavailableError (so a resolver catching the source-failure family catches it) and :class:~symfonic.core.protocols.NotFoundError (so "absent" stays distinguishable from "present but unreadable" -- an operator who mistyped a path and one whose deploy dropped read permissions need different fixes).

BlockFileUnavailableError

Bases: StorageError

The backing file exists but could not be read as block content.

A permission denial, a path that is a directory, or content that is not decodable in the declared encoding. Typed as a :class:~symfonic.core.protocols.StorageError so the resolver routes it through the block's on_source_failure policy instead of letting a raw :class:OSError escape past it.

FileBlockSource

FileBlockSource(
    path: str | Path, *, encoding: str = "utf-8"
)

Serves one prompt block from one file on disk.

Satisfies :class:~symfonic.core.prompt.blocks.protocol.BlockSource structurally and stops there -- see the module docstring for why history is not presented.

Parameters:

Name Type Description Default
path str | Path

The file backing this block. Resolved once at construction so a later chdir cannot change which file a configured source reads.

required
encoding str

Text encoding of the file. Content is always hashed as UTF-8, so this affects decoding only, never the revision.

'utf-8'

Attributes:

Name Type Description
offline_safe bool

Always True. Every read is a local filesystem read; there is no datastore, no network client and no cache on any path, including :meth:current_revision.

scope_aware bool

Always False. One file serves the whole deployment.

Source code in src/symfonic/core/prompt/blocks/sources/file.py
def __init__(self, path: str | Path, *, encoding: str = "utf-8") -> None:
    # ``Path("")`` normalises to ``Path(".")`` -- str(Path("")) is
    # already "." by the time a Path object exists, so the whitespace
    # check above cannot see the empty string a caller passed in
    # (str or Path). Comparing against Path() (also ".") catches an
    # empty string, an empty Path, and a bare "." spelled directly,
    # all of which name the current working directory rather than a
    # file.
    if not str(path).strip() or Path(path) == Path():
        raise ValueError(
            "FileBlockSource requires a path to the file backing the block; "
            "an empty path names nothing to read -- Path('') is the current "
            "directory, so accepting it would defer the mistake to a confusing "
            "IsADirectoryError at resolve time"
        )
    try:
        codecs.lookup(encoding)
    except LookupError as exc:
        raise ValueError(
            f"FileBlockSource received encoding {encoding!r}, which is not a "
            "codec Python recognises. An unvalidated typo here would otherwise "
            "break every read as a raw LookupError instead of failing at this "
            "config site -- fail_closed every turn, or omit silently forever"
        ) from exc
    # Absolute-ised at construction: a source configured at startup
    # must keep reading the same file no matter what the working
    # directory is by the time a turn resolves its blocks.
    self._path = Path(path).expanduser().absolute()
    self._encoding = encoding

encoding property

encoding: str

The text encoding used to decode the file.

path property

path: Path

The absolute path this source reads. Fixed at construction.

current_revision async

current_revision(scope: TenantScope, block_id: str) -> str

Return the current revision id without building a revision.

Not part of :class:BlockSource; offered so a cache-validity check has a cheap path that stays as offline as :meth:load -- the check reads the same local file and hashes it, and reaches no datastore either.

Source code in src/symfonic/core/prompt/blocks/sources/file.py
async def current_revision(self, scope: TenantScope, block_id: str) -> str:
    """Return the current revision id without building a revision.

    Not part of :class:`BlockSource`; offered so a cache-validity
    check has a cheap path that stays as offline as
    :meth:`load` -- the check reads the same local file and hashes
    it, and reaches no datastore either.
    """
    return content_revision(await asyncio.to_thread(self._read, block_id))

load async

load(scope: TenantScope, block_id: str) -> BlockRevision

Return the current revision of the backing file.

scope and block_id are accepted because the Protocol has no overload that omits them, and ignored because one instance backs one file: the path selects the content. block_id is still used in failure messages so an unreadable file names the block it was serving.

Raises:

Type Description
BlockFileNotFoundError

The file does not exist.

BlockFileUnavailableError

The file exists but cannot be read as text in :attr:encoding.

Source code in src/symfonic/core/prompt/blocks/sources/file.py
async def load(self, scope: TenantScope, block_id: str) -> BlockRevision:
    """Return the current revision of the backing file.

    ``scope`` and ``block_id`` are accepted because the Protocol has
    no overload that omits them, and ignored because one instance
    backs one file: the path selects the content. ``block_id`` is
    still used in failure messages so an unreadable file names the
    block it was serving.

    Raises:
        BlockFileNotFoundError: The file does not exist.
        BlockFileUnavailableError: The file exists but cannot be read
            as text in :attr:`encoding`.
    """
    content = await asyncio.to_thread(self._read, block_id)
    revision = content_revision(content)
    return BlockRevision(
        content=content,
        revision=revision,
        # Everything below is left unset on purpose. A file records
        # no author and no message, and its mtime is not a
        # trustworthy creation time (see the module docstring), so
        # inventing values here would make framework filler
        # indistinguishable from provenance the source actually knew.
        content_hash=revision.split(":", 1)[1],
    )

content_revision

content_revision(content: str) -> str

Return the revision id for content -- sha256:<hex>.

A pure function of the text, with no filesystem access at all, so the same content produces the same revision on every host and at every mtime. Exposed rather than inlined so a cache-invalidation check can compute the expected revision for content it already holds, without a second read.

The text is hashed as UTF-8 regardless of the encoding it was read in: the revision identifies the block's content, so re-saving the same words in a different on-disk encoding must not present itself as an edit.

Source code in src/symfonic/core/prompt/blocks/sources/file.py
def content_revision(content: str) -> str:
    """Return the revision id for ``content`` -- ``sha256:<hex>``.

    A pure function of the text, with no filesystem access at all, so
    the same content produces the same revision on every host and at
    every mtime. Exposed rather than inlined so a cache-invalidation
    check can compute the expected revision for content it already
    holds, without a second read.

    The text is hashed as UTF-8 regardless of the encoding it was read
    in: the revision identifies the block's *content*, so re-saving the
    same words in a different on-disk encoding must not present itself
    as an edit.
    """
    digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
    return f"{REVISION_ALGORITHM}:{digest}"