Skip to content

symfonic.core.plugins.section

section

PluginPromptSection -- PromptSection adapter that renders plugin prompts.

Aggregates contributions from all registered domain plugins into a single prompt section. Plugin errors are non-fatal; a failing plugin is skipped and the remaining plugins continue rendering.

7.4+: gains :meth:render_contributions which returns the new list[PluginContribution] surface. The legacy :meth:render method stays for external callers (and is itself implemented on top of the new dispatcher so behaviour stays in lockstep).

PluginPromptSection dataclass

PluginPromptSection(
    name: str = "Domain Plugin",
    required: bool = False,
    priority: int = 15,
    cache_breakpoint: bool = True,
    cache_ttl: Literal["5m", "1h"] | None = None,
    _last_contribs: list[PluginContribution] = list(),
    _plugins: list[BaseDomainPlugin] = list(),
    _short_circuit_warned: set[str] = set(),
    _kernel_warned: set[str] = set(),
)

PromptSection that renders content from all loaded domain plugins.

Satisfies the PromptSection protocol so it can be added to a PromptBuilder pipeline or rendered directly from the agent.

Attributes:

Name Type Description
name str

Section label shown in the composed prompt.

required bool

Whether a render failure is fatal (always False here).

priority int

Render order — 15 places it after core system context but before memory sections.

cache_breakpoint bool

Marks the section for Anthropic prompt caching. Domain rules are stable within a session so caching is safe.

add_plugin

add_plugin(plugin: BaseDomainPlugin) -> None

Register a plugin to contribute its prompt content.

Parameters:

Name Type Description Default
plugin BaseDomainPlugin

Any object satisfying BaseDomainPlugin protocol.

required
Source code in src/symfonic/core/plugins/section.py
def add_plugin(self, plugin: BaseDomainPlugin) -> None:
    """Register a plugin to contribute its prompt content.

    Args:
        plugin: Any object satisfying BaseDomainPlugin protocol.
    """
    self._plugins.append(plugin)

render async

render(state: PromptState) -> str

Render all plugin contributions into a single string.

Each plugin's output is prefixed with a ### <name> heading. Plugins that return empty strings are silently skipped. Plugins that raise exceptions are logged at DEBUG level and skipped.

7.4+: implemented on top of :meth:render_contributions so the legacy single-string surface and the new contribution surface agree byte-for-byte for plugins that only define inject_system_prompt.

Parameters:

Name Type Description Default
state PromptState

Current prompt state forwarded to each plugin.

required

Returns:

Type Description
str

Combined plugin content, or empty string if no plugins produced

str

non-empty output.

Source code in src/symfonic/core/plugins/section.py
async def render(self, state: PromptState) -> str:
    """Render all plugin contributions into a single string.

    Each plugin's output is prefixed with a ``### <name>`` heading.
    Plugins that return empty strings are silently skipped.
    Plugins that raise exceptions are logged at DEBUG level and skipped.

    7.4+: implemented on top of :meth:`render_contributions` so the
    legacy single-string surface and the new contribution surface
    agree byte-for-byte for plugins that only define
    ``inject_system_prompt``.

    Args:
        state: Current prompt state forwarded to each plugin.

    Returns:
        Combined plugin content, or empty string if no plugins produced
        non-empty output.
    """
    contribs = await self.render_contributions(state)
    # ``render_contributions`` already cached the list on
    # ``_last_contribs``; no extra bookkeeping here.
    # The legacy ``render`` surface only ever produced "cached"
    # content (it pre-dates the cache-position concept). New API
    # plugins emitting volatile contributions still flow through
    # here because external callers reach for ``render`` — but the
    # engine routes through ``render_contributions`` directly, so
    # external callers seeing a flat string see EVERY position
    # joined together. The legacy ordering ("### {name}\n{body}" per
    # plugin, joined with double-newline) is preserved by
    # ``render_position_group`` for ``header=None`` synthetic
    # contributions.
    cached = render_position_group(contribs, "cached") or ""
    volatile = render_position_group(contribs, "volatile") or ""
    parts = [p for p in (cached, volatile) if p]
    return "\n\n".join(parts)

render_contributions async

render_contributions(
    state: PromptState,
) -> list[PluginContribution]

Collect every plugin's contributions into a flat ordered list.

Wraps :func:dispatch_plugin_contributions and threads the section's per-instance latches so observability logs (legacy short-circuit INFO, kernel-position WARNING) fire at most once per (plugin, section-instance) pair.

Parameters:

Name Type Description Default
state PromptState

PromptState forwarded to each plugin.

required

Returns:

Type Description
list[PluginContribution]

Ordered list of PluginContribution objects with

list[PluginContribution]

origin populated and position normalized

list[PluginContribution]

("kernel" remapped to "cached").

Source code in src/symfonic/core/plugins/section.py
async def render_contributions(
    self,
    state: PromptState,
) -> list[PluginContribution]:
    """Collect every plugin's contributions into a flat ordered list.

    Wraps :func:`dispatch_plugin_contributions` and threads the
    section's per-instance latches so observability logs (legacy
    short-circuit INFO, kernel-position WARNING) fire at most once
    per ``(plugin, section-instance)`` pair.

    Args:
        state: PromptState forwarded to each plugin.

    Returns:
        Ordered list of ``PluginContribution`` objects with
        ``origin`` populated and ``position`` normalized
        (``"kernel"`` remapped to ``"cached"``).
    """
    contribs = await dispatch_plugin_contributions(
        self._plugins,
        state,
        short_circuit_warned=self._short_circuit_warned,
        kernel_warned=self._kernel_warned,
    )
    # v7.24.0: stash for the cache-TTL bubble-up rule.  The builder
    # consults ``resolve_cache_ttl`` AFTER ``render(state)`` returns;
    # by then this list reflects the contributions that actually
    # produced the rendered text.
    self._last_contribs = list(contribs)
    return contribs

resolve_cache_ttl

resolve_cache_ttl() -> Literal['5m', '1h'] | None

Compute the effective cache TTL hint for this section.

v7.24.0 precedence rule (architect-fixed):

  • Section-level :attr:cache_ttl wins when set. One section maps to one wire cache breakpoint, so the section's value is authoritative for the marker on its terminal block.
  • When section-level is None, the strongest contribution- level TTL from the LAST rendered contribution list bubbles up: "1h" > "5m" > None. Lets adopters declare TTL intent at the contribution granularity without splitting a section.

Callers MUST invoke this AFTER render(state) / render_contributions(state) has populated _last_contribs; otherwise the bubble-up step sees no contributions and falls back to None. This is the contract :class:PromptBuilder relies on.

Source code in src/symfonic/core/plugins/section.py
def resolve_cache_ttl(self) -> Literal["5m", "1h"] | None:
    """Compute the effective cache TTL hint for this section.

    v7.24.0 precedence rule (architect-fixed):

    * Section-level :attr:`cache_ttl` wins when set.  One section
      maps to one wire cache breakpoint, so the section's value is
      authoritative for the marker on its terminal block.
    * When section-level is ``None``, the strongest contribution-
      level TTL from the LAST rendered contribution list bubbles up:
      ``"1h"`` > ``"5m"`` > ``None``.  Lets adopters declare TTL
      intent at the contribution granularity without splitting a
      section.

    Callers MUST invoke this AFTER ``render(state)`` /
    ``render_contributions(state)`` has populated ``_last_contribs``;
    otherwise the bubble-up step sees no contributions and falls
    back to ``None``.  This is the contract :class:`PromptBuilder`
    relies on.
    """
    if self.cache_ttl is not None:
        return self.cache_ttl
    # Bubble-up: 1h beats 5m beats None.
    has_1h = False
    has_5m = False
    for c in self._last_contribs:
        if c.cache_ttl == "1h":
            has_1h = True
            break
        if c.cache_ttl == "5m":
            has_5m = True
    if has_1h:
        return "1h"
    if has_5m:
        return "5m"
    return None