Skip to content

symfonic.core.plugins.dispatcher

dispatcher

Dispatcher for inject_contributions / inject_system_prompt hooks.

Item 14 (7.4) plugin-API redesign: this module is the bridge between legacy inject_system_prompt plugins and the new inject_contributions API. The engine calls :func:dispatch_plugin_contributions once per turn; the returned list is grouped by position and rendered into the L1/L2 prompt slots.

Design note: .claude/docs/2026-05-26-plugin-api-redesign-design.md.

dispatch_plugin_contributions async

dispatch_plugin_contributions(
    plugins: list[Any],
    state: PromptState,
    *,
    short_circuit_warned: set[str] | None = None,
    kernel_warned: set[str] | None = None,
) -> list[PluginContribution]

Collect contributions from every plugin, applying hybrid dispatch.

Per the design's §4 / Q1 decision (Hybrid, Option C):

  • If hasattr(plugin, "inject_contributions"), that method is called and its returned list is used. inject_system_prompt is NOT called for this plugin (no double-emit).
  • Otherwise, inject_system_prompt is called; the returned string is wrapped in a synthetic PluginContribution(content=..., position="cached", header="") so downstream rendering uses one code path. header="" skips the auto-header here because the engine renders legacy plugins via :func:render_position_group and we want byte-identical output to the historical PluginPromptSection.render (the legacy path builds its own ### {plugin.name}\n{body} header — see the header_for_legacy argument below).

Parameters:

Name Type Description Default
plugins list[Any]

Plugin instances in registration order.

required
state PromptState

PromptState mapping forwarded to each plugin.

required
short_circuit_warned set[str] | None

Set of plugin origins that have already received the "defines both methods" INFO log (Q-C default). The caller (engine) maintains the set across turns so the log fires at most once per (plugin, agent-instance) pair.

None
kernel_warned set[str] | None

Set of plugin origins that have already received the "position=kernel reserved" WARNING log. Same lifecycle as short_circuit_warned.

None

Returns:

Type Description
list[PluginContribution]

Ordered list of PluginContribution objects with origin

list[PluginContribution]

populated and position normalized ("kernel" remapped to

list[PluginContribution]

"cached"). Sort key is

list[PluginContribution]

(position_index, contribution.order, registration_index)

list[PluginContribution]

Python's stable sort preserves registration order and intra-

list[PluginContribution]

plugin emission order on ties.

Source code in src/symfonic/core/plugins/dispatcher.py
async def dispatch_plugin_contributions(
    plugins: list[Any],
    state: PromptState,
    *,
    short_circuit_warned: set[str] | None = None,
    kernel_warned: set[str] | None = None,
) -> list[PluginContribution]:
    """Collect contributions from every plugin, applying hybrid dispatch.

    Per the design's §4 / Q1 decision (Hybrid, Option C):

    - If ``hasattr(plugin, "inject_contributions")``, that method is
      called and its returned list is used. ``inject_system_prompt`` is
      NOT called for this plugin (no double-emit).
    - Otherwise, ``inject_system_prompt`` is called; the returned string
      is wrapped in a synthetic ``PluginContribution(content=...,
      position="cached", header="")`` so downstream rendering uses one
      code path. ``header=""`` skips the auto-header here because the
      engine renders legacy plugins via
      :func:`render_position_group` and we want byte-identical output
      to the historical ``PluginPromptSection.render`` (the legacy path
      builds its own ``### {plugin.name}\\n{body}`` header — see the
      ``header_for_legacy`` argument below).

    Args:
        plugins: Plugin instances in registration order.
        state: ``PromptState`` mapping forwarded to each plugin.
        short_circuit_warned: Set of plugin origins that have already
            received the "defines both methods" INFO log (Q-C default).
            The caller (engine) maintains the set across turns so the
            log fires at most once per (plugin, agent-instance) pair.
        kernel_warned: Set of plugin origins that have already received
            the "position=kernel reserved" WARNING log. Same lifecycle
            as ``short_circuit_warned``.

    Returns:
        Ordered list of ``PluginContribution`` objects with ``origin``
        populated and ``position`` normalized (``"kernel"`` remapped to
        ``"cached"``). Sort key is
        ``(position_index, contribution.order, registration_index)`` —
        Python's stable sort preserves registration order and intra-
        plugin emission order on ties.
    """
    if short_circuit_warned is None:
        short_circuit_warned = set()
    if kernel_warned is None:
        kernel_warned = set()

    collected: list[tuple[int, int, PluginContribution]] = []

    for reg_index, plugin in enumerate(plugins):
        origin = _plugin_origin(plugin)
        has_new = hasattr(plugin, "inject_contributions")
        if has_new:
            # Migration smell: plugin defines BOTH methods. Per Q-C
            # default we log at INFO so the situation surfaces without
            # being noisy. The legacy method is NOT called.
            defines_legacy = _defines_inject_system_prompt(plugin)
            if defines_legacy and origin not in short_circuit_warned:
                logger.info(
                    "Plugin %r defines both inject_contributions and "
                    "inject_system_prompt; only inject_contributions is "
                    "called. Move any remaining content into "
                    "inject_contributions or delete the legacy method.",
                    origin,
                )
                short_circuit_warned.add(origin)

            contribs = await _call_inject_contributions(plugin, state, origin)
        else:
            contribs = await _call_inject_system_prompt(plugin, state, origin)

        for ci, contrib in enumerate(contribs):
            normalized = _normalize_contribution(
                contrib, origin=origin, kernel_warned=kernel_warned,
            )
            if normalized is None:
                continue
            collected.append((reg_index, ci, normalized))

    # Stable sort by (position_index, order, registration_index,
    # intra-plugin index). Python's sorted() is Timsort and stable, so
    # ties on the sort key resolve in input order — but we encode the
    # registration index in the key anyway so the result is
    # deterministic regardless of the engine's traversal order.
    def _key(item: tuple[int, int, PluginContribution]) -> tuple[int, int, int, int]:
        reg_index, ci, c = item
        pos_index = _POSITION_SORT_INDEX.get(c.position, 99)
        return (pos_index, c.order, reg_index, ci)

    return [c for _r, _i, c in sorted(collected, key=_key)]