Skip to content

symfonic.core.plugins.base

base

BaseDomainPlugin -- protocol for domain-specific agent behavior plugins.

Plugins inject domain knowledge, guardrails, and tools into the agent without modifying the core library. Multiple plugins can be loaded and their contributions are chained in registration order.

BaseDomainPlugin

Bases: Protocol

Protocol for domain-specific agent behavior plugins.

Plugins provide three REQUIRED hooks:

  • inject_system_prompt: adds domain instructions to the system prompt (single string contribution).
  • validate_state_transition: guardrails that can block agent actions.
  • get_domain_tools: MUST return []. Domain tools are passed to the constructor via SymfonicAgent(tools=[...]) — the ToolRegistry freezes when the graph compiles in __init__, and load_plugin() runs after that, so a plugin cannot add tools. A non-empty return raises SymfonicAgentError (see get_domain_tools below). The hook is vestigial for tools and kept only for Protocol compatibility.

Plugins MAY ALSO define ONE OPTIONAL hook (added in 7.4):

  • inject_contributions: returns list[PluginContribution] so a plugin can emit content at multiple cache positions in one call ("cached" prefix vs. "volatile" suffix). If defined, this method REPLACES inject_system_prompt for prompt rendering — the legacy method is NOT called for plugins that implement the new API. Plugin authors migrating from inject_system_prompt can leave the legacy method as return "" or delete it (the Protocol still considers the class compliant as long as the name exists; an empty stub satisfies that).

Signature::

  async def inject_contributions(
      self,
      state: PromptState,
  ) -> list[PluginContribution]: ...

See symfonic.core.plugins.PluginContribution for the contribution shape and the design note at .claude/docs/2026-05-26-plugin-api-redesign-design.md.

Cache benefit on position="volatile" requires FrameworkConfig.prompt_layer_mode="stratigraphic". On the JIT path, volatile contributions are appended to the flat prompt body with no separate L2 region; the engine emits a one-time WARNING log per agent instance the first time this is observed.

inject_contributions is NOT part of the runtime-checkable Protocol surface — that would break isinstance(plugin, BaseDomainPlugin) for every legacy plugin. Dispatch happens via hasattr at the engine call site.

All hooks are called by the agent at appropriate lifecycle points. Implement this protocol to inject business-specific behavior without modifying the core library.

get_domain_tools

get_domain_tools() -> list[Any]

Soft-deprecated (v8.6.6). MUST return [].

Domain tools are NOT registered through plugins. Pass them to the constructor instead::

SymfonicAgent(tools=[my_tool, ...])

The ToolRegistry freezes when the agent graph compiles inside SymfonicAgent.__init__. load_plugin() runs only after that point, so any tools returned here can no longer be registered: load_plugin() raises SymfonicAgentError on a non-empty return. There is no pre-compile plugin path that collects these tools, so a non-empty return is always an error.

This hook is retained only for plugin-Protocol compatibility (like the inject_system_prompt 7.4 soft-deprecation); it is not removed.

Returns:

Type Description
list[Any]

Always [].

Source code in src/symfonic/core/plugins/base.py
def get_domain_tools(self) -> list[Any]:
    """**Soft-deprecated (v8.6.6). MUST return ``[]``.**

    Domain tools are NOT registered through plugins. Pass them to the
    constructor instead::

        SymfonicAgent(tools=[my_tool, ...])

    The ``ToolRegistry`` freezes when the agent graph compiles inside
    ``SymfonicAgent.__init__``. ``load_plugin()`` runs only after that
    point, so any tools returned here can no longer be registered:
    ``load_plugin()`` raises ``SymfonicAgentError`` on a non-empty return.
    There is no pre-compile plugin path that collects these tools, so a
    non-empty return is *always* an error.

    This hook is retained only for plugin-Protocol compatibility (like the
    ``inject_system_prompt`` 7.4 soft-deprecation); it is not removed.

    Returns:
        Always ``[]``.
    """
    ...

inject_system_prompt async

inject_system_prompt(state: PromptState) -> str

Return domain-specific system prompt content.

DEPRECATION NOTICE (7.4, soft): plugins SHOULD migrate to inject_contributions (see the class docstring) for multi-position support and cache hygiene. The legacy method remains supported indefinitely; no DeprecationWarning is emitted. Hard removal is deferred until adoption telemetry exists.

Called during HMS prompt composition. The returned text is appended as a section to the system prompt.

The state mapping is guaranteed to contain at least these stable keys on every render, across both the JIT and stratified prompt-assembly paths (v7.3.x+):

TENANT_ID    str  -- active tenant for this turn
NAMESPACE    str  -- tenant namespace (or sub-tenant id)
DOMAIN_NAME  str  -- FrameworkConfig domain identifier
QUERY        str  -- user query for this turn; empty string
                     when no query is available (never None,
                     never missing)

Additional context keys MAY be present and vary between the JIT and stratified strategies -- for example TOOL_MANIFEST, MEMORY_CONTEXT, SOUL_SCHEMA, CORE_PREFERENCES, ACTIVE_TOOLS, HYDRATED_CONTEXT, REQUIRED_LABELS, ONBOARDING_CHECKLIST, CURRENT_TIMESTAMP, PROSPECTIVE_TASKS, DOMAIN_DESCRIPTION. Plugins MUST use state.get(key, default) for any non-stable key.

QUERY was added to the contract in v7.3.x to unblock dynamic-retrieval plugins (vector search, RAG, query-conditioned HMS scoping); previously plugins had no race-free path to the user query (lifecycle hooks fire past prompt assembly, and plugin instances are shared across concurrent runs).

Parameters:

Name Type Description Default
state PromptState

Current prompt state. See the stable-key list above.

required

Returns:

Type Description
str

Domain instructions string (empty string to skip).

Source code in src/symfonic/core/plugins/base.py
async def inject_system_prompt(self, state: PromptState) -> str:
    """Return domain-specific system prompt content.

    DEPRECATION NOTICE (7.4, soft): plugins SHOULD migrate to
    ``inject_contributions`` (see the class docstring) for
    multi-position support and cache hygiene. The legacy method
    remains supported indefinitely; no ``DeprecationWarning`` is
    emitted. Hard removal is deferred until adoption telemetry
    exists.

    Called during HMS prompt composition. The returned text is
    appended as a section to the system prompt.

    The ``state`` mapping is guaranteed to contain at least these
    stable keys on every render, across both the JIT and stratified
    prompt-assembly paths (v7.3.x+):

        TENANT_ID    str  -- active tenant for this turn
        NAMESPACE    str  -- tenant namespace (or sub-tenant id)
        DOMAIN_NAME  str  -- FrameworkConfig domain identifier
        QUERY        str  -- user query for this turn; empty string
                             when no query is available (never None,
                             never missing)

    Additional context keys MAY be present and vary between the JIT
    and stratified strategies -- for example ``TOOL_MANIFEST``,
    ``MEMORY_CONTEXT``, ``SOUL_SCHEMA``, ``CORE_PREFERENCES``,
    ``ACTIVE_TOOLS``, ``HYDRATED_CONTEXT``, ``REQUIRED_LABELS``,
    ``ONBOARDING_CHECKLIST``, ``CURRENT_TIMESTAMP``,
    ``PROSPECTIVE_TASKS``, ``DOMAIN_DESCRIPTION``. Plugins MUST use
    ``state.get(key, default)`` for any non-stable key.

    ``QUERY`` was added to the contract in v7.3.x to unblock
    dynamic-retrieval plugins (vector search, RAG, query-conditioned
    HMS scoping); previously plugins had no race-free path to the
    user query (lifecycle hooks fire past prompt assembly, and
    plugin instances are shared across concurrent runs).

    Args:
        state: Current prompt state. See the stable-key list above.

    Returns:
        Domain instructions string (empty string to skip).
    """
    ...

validate_state_transition async

validate_state_transition(
    action: str, context: dict[str, Any]
) -> bool

Validate whether an agent action should be allowed.

Called before tool execution or state changes. Return False to block the action.

Parameters:

Name Type Description Default
action str

Name of the action/tool being attempted.

required
context dict[str, Any]

Current execution context (tenant, params, etc.)

required

Returns:

Type Description
bool

True to allow, False to block.

Source code in src/symfonic/core/plugins/base.py
async def validate_state_transition(
    self,
    action: str,
    context: dict[str, Any],
) -> bool:
    """Validate whether an agent action should be allowed.

    Called before tool execution or state changes. Return False
    to block the action.

    Args:
        action: Name of the action/tool being attempted.
        context: Current execution context (tenant, params, etc.)

    Returns:
        True to allow, False to block.
    """
    ...