Skip to content

symfonic.capabilities.prompting.hms

hms

The HMS system prompt, as a contribution the compiler owns (#21, path A).

Legacy renders this through HMSSystemPromptSection and then asks it whether the result fits a budget; the engine reads that verdict and may ignore it. The compiler, meanwhile, has its own budget that drops content. Keeping both after the port would mean one prompt judged twice by different rules, one advisory and one destructive — so the split here is deliberate and total:

  • the source renders and validates. Placeholder substitution, the upper/lower key fallback, the unresolved-placeholder refusal and the tenant check are this class's contract, and they are the reason a port was chosen over a rewrite.
  • the compiler decides what survives. No trimming, no truncation and no budget verdict happens here. :func:estimate_tokens is kept for characterization parity and is deliberately not consulted by read.

The trust tier is the other half, and it inverts the memory case. This text is authored — a human wrote the template — so it is not untrusted input and must not be delimited as such. A recalled memory is aggregated from what a user said and defaults to untrusted=True; wrapping the system prompt the same way would tell the model its own instructions are attacker-influenced.

HmsSystemSource dataclass

HmsSystemSource(state: dict[str, Any] = dict(), template_path: Path | None = None, untrusted: bool = False, offline_safe: bool = True, scope_aware: bool = True)

Renders the HMS system template for one turn's state.

state is held on the instance rather than read from a global, so two agents in one process render two prompts. It carries the {{VARIABLE}} values the template declares, including TENANT_ID.

Not frozen, and the reason is the protocol rather than a change of mind: ContributionSource declares offline_safe and scope_aware as settable, so a frozen dataclass cannot satisfy it. The isolation this class needs comes from copying state at construction instead — a caller that mutates the dict it passed in does not reach the rendered prompt, which is the property that actually mattered.

estimate_tokens staticmethod

estimate_tokens(text: str) -> int

Legacy's conservative estimate, kept for characterization only.

len // 3 rather than len // 4 because the shorter divisor under-counts structured text by 20-30%. Retained so a parity test can compare the two paths on the same number, and not consulted by :meth:read: the compiler owns the budget, and a second estimator reachable from a source is how two gates reappear.

Source code in src/symfonic/capabilities/prompting/hms.py
@staticmethod
def estimate_tokens(text: str) -> int:
    """Legacy's conservative estimate, kept for characterization only.

    ``len // 3`` rather than ``len // 4`` because the shorter divisor
    under-counts structured text by 20-30%. Retained so a parity test can
    compare the two paths on the same number, and **not** consulted by
    :meth:`read`: the compiler owns the budget, and a second estimator
    reachable from a source is how two gates reappear.
    """
    return HMSSystemPromptSection.estimate_tokens(text)

read

read(request: SourceRequest) -> SourceRead

Render the template, or refuse.

Both refusals are the legacy ones and both are deliberate: a missing tenant raises SecurityScopeError before any rendering, because the tenant is what binds this prompt to the data it may discuss; an unresolved placeholder raises ValueError rather than blanking, because a blanked placeholder ships a grammatical, plausible system prompt with an instruction missing.

The revision is the rendered text's own digest, so a template or state change moves the cache key. Nothing here consults a budget.

Source code in src/symfonic/capabilities/prompting/hms.py
def read(self, request: SourceRequest) -> SourceRead:
    """Render the template, or refuse.

    Both refusals are the legacy ones and both are deliberate:
    a missing tenant raises ``SecurityScopeError`` before any rendering,
    because the tenant is what binds this prompt to the data it may discuss;
    an unresolved placeholder raises ``ValueError`` rather than blanking,
    because a blanked placeholder ships a grammatical, plausible system
    prompt with an instruction missing.

    The revision is the rendered text's own digest, so a template or state
    change moves the cache key. Nothing here consults a budget.
    """
    # The tenant comes from the *request*, never from the state the source
    # was built with. The bundle is folded once at construction, so a
    # TENANT_ID baked into ``state`` renders every tenant's prompt as
    # whichever scope happened to exist then -- review caught exactly that:
    # legacy rendered "Tenant: acme" and the migrated path "Tenant: default"
    # on the same turn. The prompt was present, well-formed, and about the
    # wrong tenant.
    state = dict(self.state)
    tenant = _tenant_of(request.scope_path)
    if tenant:
        state["TENANT_ID"] = tenant
    rendered = _render(state, self.template_path)
    return SourceRead(
        text=rendered,
        revision=_digest(rendered, request.contribution_id),
        untrusted=self.untrusted,
    )

hms_contribution

hms_contribution(source: HmsSystemSource, *, order: int = -1000) -> Any

Declare the HMS system prompt to the compiler, at an authored tier.

L0 and platform because the text is the deployment's own identity, written by a human: the highest layer and the highest authority tier, which is what places it ahead of anything learned and lets the render gate leave it undelimited.

deployment scope for the same reason. The recall it will sit above is tenant-scoped and re-checked per turn; this template is the same for every tenant the process serves, and scoping it narrower would invite a per-tenant system prompt that nothing here validates.

order is very negative so the system prompt leads its layer. It is a default rather than a constant because ordering_key is (layer, order, contribution_id) and an adopter with two L0 contributions needs a way to say which comes first -- but the default must not tie, or the alphabetical tie-break decides identity ordering by accident.

Source code in src/symfonic/capabilities/prompting/hms.py
def hms_contribution(
    source: HmsSystemSource,
    *,
    order: int = -1000,
) -> Any:
    """Declare the HMS system prompt to the compiler, at an authored tier.

    ``L0`` and ``platform`` because the text is the deployment's own identity,
    written by a human: the highest layer and the highest authority tier, which
    is what places it ahead of anything learned and lets the render gate leave
    it undelimited.

    ``deployment`` scope for the same reason. The recall it will sit above is
    tenant-scoped and re-checked per turn; this template is the same for every
    tenant the process serves, and scoping it narrower would invite a per-tenant
    system prompt that nothing here validates.

    ``order`` is very negative so the system prompt leads its layer. It is a
    default rather than a constant because ``ordering_key`` is ``(layer, order,
    contribution_id)`` and an adopter with two L0 contributions needs a way to
    say which comes first -- but the default must not tie, or the alphabetical
    tie-break decides identity ordering by accident.
    """
    from symfonic.capabilities.prompting.contracts import (
        ContributionScope,
        PromptContribution,
        TrustTier,
    )
    from symfonic.capabilities.prompting.layers import Layer

    return PromptContribution(
        contribution_id=HMS_CONTRIBUTION_ID,
        source=source,
        capability="prompting",
        layer=Layer.L0,
        tier=TrustTier.PLATFORM,
        scope=ContributionScope.DEPLOYMENT,
        order=order,
    )