Skip to content

Guide 12 — State-Predicate Preconditions (v7.18.0)

Version: v7.18.0+ Status: Stable Related: Guide 10 — Authored Procedural Skills, Guide 03 — Streaming & Callbacks

Authored procedural skills can now gate themselves on runtime state in addition to prior tool calls. This guide covers the {"state": {...}} precondition DSL added in v7.18.0.

Why state predicates

Pre-v7.18, a skill's metadata['precondition'] could only require that a specific tool had been called. That works for ordering rules ("call get_status before send_message") but not for conditional behaviour — "use Slack mrkdwn formatting only when the user is chatting from Slack".

Three production patterns motivated the DSL:

  1. Platform-conditional rules. "Force slack_upload_file for CSV exports — but only when platform == 'slack'. On web, the default download path applies."
  2. Per-user memory keys. "When the user is in a multi-user Slack channel, scope memory_write to their slack_user_name so two users in the same channel don't mix memories. (This was an adopter's SL-04 tenant-data-leak blocker.)"
  3. Per-mode deep-link rendering. "Format internal flow links as Slack mrkdwn deep-links on Slack, plain markdown elsewhere."

Each of these is a skill that should not even appear in the L1 PRE-FLIGHT rules block when the runtime context excludes it. State predicates make that explicit.

DSL shape

A skill's precondition field is now str | list[str | dict] | None. List entries may be:

Entry shape Meaning
"tool_name" v7.5+ bare-identifier tool-history check (unchanged).
{"tool": "tool_name"} Explicit dict form of the bare-id (forward-compat).
{"state": {"path": ..., "op": ..., "value": ...}} v7.18.0 state predicate.

State predicates are conjunctive: a skill must satisfy every state predicate on its list to participate in this turn's L1 PRE-FLIGHT block and reactive gate.

Operators (v1)

The v7.18.0 operator set is locked at four:

op Meaning Requires value?
eq state[path] == value yes
neq state[path] != value yes
in state[path] appears in value (a list/tuple/set) yes
exists the key resolves (including explicit None) no

Deferred to a future ship: lt, gt, contains, regex. The regex operator in particular carries a DoS surface (adopter-supplied patterns can ReDoS); if you need it, open an issue with a real production case and we'll evaluate it for v7.19+.

Path resolution

The path is dotted ("page_context.platform"). Each segment is resolved by trying UPPER_SNAKE first, then snake_case. This mirrors the long-standing precedence at engine.py:1654-1670 for TENANT_ID / tenant_id and lets a single skill author work against both the plugin-render contract (UPPER_SNAKE) and the LangGraph runtime state (snake_case) without authoring two rules.

A missing path resolves to a private _MISSING sentinel, not None. This is important for exists: state[key] = None returns True; an absent key returns False.

in rejects string value

{"op": "in", "value": "slackweb"} would silently substring-match if we used Python's default in semantics. The validator rejects non-list/tuple/set value for in to keep the surface predictable. Use ["slack", "web"] instead of "slackweb".

Example — gate a Slack-only formatting rule

from symfonic.memory.models.entry import MemoryEntry
from symfonic.memory.types import MemoryLayer

skill = MemoryEntry(
    layer=MemoryLayer.PROCEDURAL,
    tenant_id=scope.tenant_id,
    content=(
        "When delivering responses on Slack, format internal flow "
        "links as Slack mrkdwn deep-links: <flow_url|flow_title>."
    ),
    importance=8.0,
    metadata={
        "label": "Slack mrkdwn deep-links",
        "action_tool": "format_flow_links",
        "steps": ["format_flow_links"],
    },
)

await procedural.seed_authored_skill(
    scope, skill,
    source_tag="authored:slack-skills",
    precondition=[
        {
            "state": {
                "path": "page_context.platform",
                "op": "eq",
                "value": "slack",
            },
        },
    ],
)

On a Slack turn (state["page_context"]["platform"] == "slack"), the skill's bullet appears in the L1 PRE-FLIGHT block:

## ENFORCED PRE-FLIGHT RULES
- YOU MUST call `format_flow_links` BEFORE [...] (skill: Slack mrkdwn deep-links).
- DO NOT [...] until `format_flow_links` has been called [...].

On a web turn (state["page_context"]["platform"] == "web"), the bullet is absent — the cached L1 prefix doesn't carry it and the reactive gate doesn't enforce it.

Example — combined tool + state precondition

The SL-04 Slack tenant-data-leak shape: a memory_write call must scope its title to slack_user_name whenever both (a) the user is in Slack AND (b) the Slack user-id is resolvable.

await procedural.seed_authored_skill(
    scope, skill,
    source_tag="authored:slack-per-user-memory",
    precondition=[
        "memory_read_user_context",
        {"state": {
            "path": "page_context.platform",
            "op": "eq",
            "value": "slack",
        }},
        {"state": {
            "path": "page_context.slack_user_name",
            "op": "exists",
        }},
    ],
)

The list mixes a bare tool-history rule (memory_read_user_context must have been called this turn) with two state predicates. The reactive gate enforces both channels: the skill drops from the active set if either state predicate fails, and the tool-history check fires the standard v7.7.4 synthetic error if memory_read_user_context hasn't been called.

Where the filter runs

State predicates are evaluated at two sites:

  1. L1 PRE-FLIGHT synthesizer (preflight_l1._build_preflight_body). Skills whose predicates fail are dropped before bullets are emitted. The L1 cached prefix invalidates per platform class (slack vs web), not per state delta — skills with no state predicates render byte-identical for the same snapshot, so Anthropic prompt-cache hit rates are preserved.

  2. Reactive precondition_gate (core/nodes/precondition_gate.py). Skills whose predicates fail are dropped from the by_action index before the tool-call scan. Off-platform skills never fire a ToolMessage(status="error") at the model.

The single filter helper (filter_skills_by_state_predicates) lives in symfonic.core.nodes.precondition_gate and is shared by both sites.

Debugging — DEBUG telemetry

Every skill the filter drops emits one DEBUG log line on the symfonic.core.nodes.precondition_gate logger:

skill `slack_only` dropped this turn: state predicate `page_context.platform` eq `'slack'` -> False (state value: 'web')

Enable it with:

import logging
logging.getLogger("symfonic.core.nodes.precondition_gate").setLevel(logging.DEBUG)

The line names the skill, the path, the operator, the expected value, and the resolved state value — enough to diagnose "why isn't my Slack-only skill firing" without diffing the dispatched prompt.

HTTP / YAML adopter ergonomics

The POST /memory/procedural endpoint accepts the new dict shapes on its precondition field via ProceduralCreateRequest. The validator fails malformed shapes with a 422 at the boundary so callers don't get silently-dropped skills:

POST /memory/procedural
{
  "label": "Slack-only mrkdwn",
  "content": "...",
  "precondition": [
    {"state": {
      "path": "page_context.platform",
      "op": "eq",
      "value": "slack"
    }}
  ]
}

YAML adopters can author the same shape directly:

- label: Slack-only mrkdwn
  content: ...
  precondition:
    - state:
        path: page_context.platform
        op: eq
        value: slack

YAML's anchor + tag system means a single platform predicate can be re-used across N skills:

.platform-slack: &platform-slack
  state:
    path: page_context.platform
    op: eq
    value: slack

skills:
  - label: Slack mrkdwn deep-links
    precondition: [*platform-slack]
  - label: Slack file-upload tool selector
    precondition: [*platform-slack, "get_integration_status"]

Composability with v7.7.4 / v7.7.5 / v7.8.3

State predicates are a filter, not a replacement, for the existing precondition mechanisms. The compositional rules:

  • A skill with state predicates AND tool-history preconditions runs both gates. The tool-history channel fires synthetic ToolMessage errors as in v7.7.4. The state channel just drops the skill before it ever reaches that scan.
  • A skill with state predicates but no action_tool participates in the L1 filter (the bullets simply aren't emitted because there's no tool to render against) but does NOT fire the reactive gate.
  • The v7.8.3 dual-bullet (positive + negative) emission applies to surviving skills only.
  • The v7.8.3 procedural_force_first_action_tool knob runs against the post-filter skill set, so off-platform skills do not force tool choices.

Common pitfalls

  • exists does not equal "truthy". A False value, an empty list, an empty dict, an empty string — all return True for exists. Use eq with the value you want, not exists, to check for a specific value.
  • in requires a list/tuple/set. Strings are rejected to prevent the silent substring-match foot-gun. A scalar value can be wrapped: {"op": "in", "value": ["slack"]} is the explicit way to check a single-element membership.
  • Missing path fails every value-taking op. If you want "absent OR equals X" you must compose two skills with different preconditions. v7.18 deliberately keeps conjunctive-only semantics in scope.
  • Cache parity. The L1 cached prefix only invalidates per platform class. If your state varies per-turn within a class (e.g. slack_user_name changes), the bullet text does NOT vary per-turn — it's still keyed off the skill snapshot, not the state delta. State-conditional rendering (rewriting bullet text to carry per-user-id details) is the on_response_render callback (Option B) — see Guide 03.

Migration from v7.7.x — v7.17.x

The DSL is purely additive. Existing skills with bare-string or list[str] preconditions continue to work byte-for-byte unchanged.

To opt in:

  1. Update your seed_authored_skill calls to pass dict-shaped entries in the precondition list.
  2. Optional: enable DEBUG logging on symfonic.core.nodes.precondition_gate for the first deploy so you can see the filter outcomes in real time.
  3. Adopters using the HTTP POST /memory/procedural endpoint get the new shapes automatically — the Pydantic validator accepts them on the wire.

No FrameworkConfig knob change is required: the DSL piggybacks on the same procedural_render_preflight_in_l1 and procedural_enforce_preconditions knobs that already gate the v7.7.4 and v7.7.5 paths.

Option B — on_response_render callback (v7.18.0)

Companion to Option A in the same ship. A new optional callback handler hook lets adopters rewrite the final response just before it reaches the user. Typical use: translate Markdown to Slack mrkdwn for delivery in a Slack channel.

from symfonic.core.contracts.callbacks import ResponseRenderEvent

class SlackFormattingHandler:
    async def on_response_render(
        self,
        content: str,
        state: dict[str, Any],
        metadata: ResponseRenderEvent,
    ) -> str:
        if state.get("page_context", {}).get("platform") == "slack":
            return content.replace("**", "*")  # md bold -> mrkdwn bold
        return content

response = await agent.run("Hello", callbacks=[SlackFormattingHandler()])
# response.final_response is the rewritten string

Emission ordering (locked v7.18.0)

The rewriter runs at a specific point in the response pipeline:

react loop  ->  salvage  ->  extraction-stripping  ->  fabrication
            ->  metacog  ->  on_response_render  ->  on_agent_end

Key consequences:

  • The rewriter sees the post-fabrication, post-metacog content the adopter would otherwise receive — not the raw model output.
  • on_agent_end fires AFTER the rewriter, so observability consumers see the rewritten content in event.final_response. The engine's AgentResponse.final_response carries the same string.
  • The rewriter runs at most ONCE per turn. Multiple registered handlers chain in registration order — each handler receives the previous one's output.
  • A handler that raises is skipped (logged); the chain proceeds with the previous content. A handler that returns a non-string value is silently dropped. The runtime never delivers non-string content to adopters.

Scope — agent.run() only

on_response_render does NOT fire from agent.stream() or agent.stream_events(). The carve-out is deliberate:

  1. Delta granularity. stream_events() yields TextDeltaEvent chunks. Rewriting "**bold**" → "*bold*" at delta granularity is impossible — the ** opening might arrive in one chunk and the ** closing in another. Buffering the whole stream to apply a rewrite defeats streaming.
  2. Consistency with v7.17.0 synthesize_on_empty_final, which is also documented sync-only for the same reason.

Adopters who need streaming rewrites implement them client-side OR buffer in their adopter layer. The framework's stream emits raw deltas; transformation is the consumer's responsibility.

Behavioural change — on_agent_end timing

Pre-v7.18, on_agent_end fired from the runtime layer (AgentRuntime.run). v7.18.0 moves the success-path emission to the engine layer, AFTER the full transformation chain. The event now carries:

  • The rewritten content (not the pre-rewrite original).
  • Slightly later wall-clock timing (a few ms — negligible).

Migration note. Adopters relying on runtime-layer-only on_agent_end timing should switch to one of the more-specific events:

  • on_llm_pre_call — fires before the LLM call, with the wire-accurate payload.
  • on_react_loop_end (v7.17.0) — fires when the React loop terminates.
  • on_fabrication_detected — fires when the fabrication check produces findings.

The error-path on_agent_end still fires from the runtime layer for BaseException terminators (engine can't intercept those).