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:
- Platform-conditional rules. "Force
slack_upload_filefor CSV exports — but only whenplatform == 'slack'. On web, the default download path applies." - Per-user memory keys. "When the user is in a multi-user Slack
channel, scope
memory_writeto theirslack_user_nameso two users in the same channel don't mix memories. (This was an adopter's SL-04 tenant-data-leak blocker.)" - 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. Theregexoperator 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:
-
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 (slackvsweb), not per state delta — skills with no state predicates render byte-identical for the same snapshot, so Anthropic prompt-cache hit rates are preserved. -
Reactive
precondition_gate(core/nodes/precondition_gate.py). Skills whose predicates fail are dropped from theby_actionindex before the tool-call scan. Off-platform skills never fire aToolMessage(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:
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_toolparticipates 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_toolknob runs against the post-filter skill set, so off-platform skills do not force tool choices.
Common pitfalls¶
existsdoes not equal "truthy". AFalsevalue, an empty list, an empty dict, an empty string — all returnTrueforexists. Useeqwith the value you want, notexists, to check for a specific value.inrequires 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_namechanges), 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 theon_response_rendercallback (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:
- Update your
seed_authored_skillcalls to pass dict-shaped entries in thepreconditionlist. - Optional: enable DEBUG logging on
symfonic.core.nodes.precondition_gatefor the first deploy so you can see the filter outcomes in real time. - Adopters using the HTTP
POST /memory/proceduralendpoint 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_endfires AFTER the rewriter, so observability consumers see the rewritten content inevent.final_response. The engine'sAgentResponse.final_responsecarries 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:
- Delta granularity.
stream_events()yieldsTextDeltaEventchunks. 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. - 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).