Logic Programming
The logic-policy behavior-tree node runs deterministic rules over a set of facts — it’s the symbolic half of the neurosymbolic loop. Where an LLM node perceives (turning messy input into facts), the logic-policy node reasons: it derives conclusions you can audit, from rules a human wrote and can point to.
Rules are plain text you author and version alongside your agent’s tree. This page teaches the rule language and how to wire up a logic-policy node.
Why rules?
A language model is good at reading novel input and bad at multi-step inference it can’t show. Rules are the opposite: rigid about input, but every conclusion is grounded in an authored rule you can read. Splitting the work — neural perception, symbolic reasoning — gives you agents that are both flexible and verifiable.
The language
A rule program is built from relations. A relation is a named set of tuples — think of it as a table. You state some relations as plain facts and derive others with rules. Variables are lowercase identifiers; string values are written in double quotes.
Facts
A fact is a relation defined by listing its tuples. Each tuple is parenthesized; a one-element tuple needs a trailing comma:
rel touches = {("pr_482", "ui")}
rel sensitive = {("auth",), ("billing",)}
rel has_tests = {("pr_482",)}touches is a two-column relation; sensitive and has_tests each have one column.
Rules
A rule derives a relation from others. The head is on the left of =, the body on the right, and and joins the goals in the body:
% A change touches something sensitive if it touches an area that is sensitive
rel touches_sensitive(c) = touches(c, area) and sensitive(area)c and area are variables — they match any value. The rule populates touches_sensitive with every c for which some matching area exists in both touches and sensitive.
Every variable in a rule’s head must also appear in its body. There are no free variables — bind each head column to something the body proves (here, c comes from touches).
Multiple rules are “or”
Define the same relation with more than one rule and the results are unioned — the relation holds a tuple if any of its rules produces it:
rel safe_change(c) = has_tests(c) and not touches_sensitive(c)
rel safe_change(c) = pre_approved(c)Negation
not goal in a body holds when goal cannot be derived. Use it for “unless” conditions, as in safe_change above (“has tests and not touching anything sensitive”). Keep negation well-founded — don’t define a relation in terms of the negation of itself.
Confidence (optional)
Facts can carry a probability, written p:: before the tuple. The engine then propagates confidence through your rules and reports it on each conclusion:
rel intent = {0.8::("wash",)}
rel transport_available = {0.95::("drive",)}If you don’t tag facts with probabilities, every conclusion simply comes back at full confidence. See semirings below for how confidence is combined.
Queries
A query names the relation you want the engine to compute. A query of a derived relation enumerates every tuple in it:
query may_auto_mergeYou can also pin one or more columns to filter the results — see the query field in the node config.
A worked example
A small policy that decides whether a change can auto-merge:
% --- Stable knowledge (rules you maintain) ---
rel sensitive = {("auth",), ("billing",)}
rel touches_sensitive(c) = touches(c, area) and sensitive(area)
rel safe_change(c) = has_tests(c) and not touches_sensitive(c)
% Only fires when the "fast_track" rule-set is selected
rel may_auto_merge(c) = rule_enabled("fast_track") and safe_change(c)Given the facts touches = {("pr_482", "ui")} and has_tests = {("pr_482",)}, the query may_auto_merge yields pr_482 — there are tests, ui isn’t sensitive, and (if fast_track is selected) the change may merge. Add ("pr_482", "auth") to touches and it drops out, because touches_sensitive now holds for it.
Feeding the engine from a behavior tree
The rules stay fixed; an LLM node supplies the variable parts through three typed channels on the blackboard:
| Channel | Field | What it carries |
|---|---|---|
| Facts | factsKey | Facts the LLM extracted from input, merged with any inline facts. Each is written in predicate(arg, arg) form, e.g. touches(pr_482, ui). |
| Query filter | query | {{blackboardKey}} placeholders in the query are filled from the blackboard before evaluation. |
| Rule selection | ruleSelectionKey | Names of rule-sets to enable. Each becomes a rule_enabled("Name") fact. |
That last channel is the gating convention: guard a rule on rule_enabled("some_policy") and it only fires when some_policy is selected. It lets you keep several policy variants in one program and switch between them at runtime — while the LLM never gets to rewrite a rule, only pick from the ones you allow.
A typical pipeline: an llm-action extracts facts to the blackboard, then a logic-policy node reads them and decides.
Facts supplied through facts / factsKey use the simpler predicate(arg, arg) form. The relations and rules in your program use the rel name = { ... } form. Both describe the same relations — the program declares the rules; the channels feed in the facts for this run.
Writing a logic-policy node
{
"type": "logic-policy",
"name": "CheckMergePolicy",
"program": "rel sensitive = {(\"auth\",), (\"billing\",)}\nrel touches_sensitive(c) = touches(c, area) and sensitive(area)\nrel safe_change(c) = has_tests(c) and not touches_sensitive(c)\nrel may_auto_merge(c) = rule_enabled(\"fast_track\") and safe_change(c)",
"factsKey": "extractedFacts.facts",
"ruleSelection": ["fast_track"],
"query": "may_auto_merge({{changeId}})",
"outputKey": "mergeDecision",
"succeedOnSolutions": true
}| Field | Description |
|---|---|
program | Required. Inline rule program — your relations and rules. |
query | Required. Name of the relation to read, e.g. "may_auto_merge". May pin columns and interpolate from the blackboard, e.g. "may_auto_merge({{changeId}})". |
outputKey | Required. Where the result object is written on the blackboard. |
facts | Inline facts (array of predicate(arg, arg) strings). |
factsKey | Blackboard key holding LLM-extracted facts; supports dotted paths like extractedFacts.facts. Merged with facts. |
ruleSelection | Rule-set names to enable, each asserted as rule_enabled("Name"). |
ruleSelectionKey | Blackboard key holding the rule selection. Merged with ruleSelection. |
semiring | How confidence is combined (see below). { "kind": "top-k-proofs", "k": 3 } (default) or { "kind": "min-max-prob" }. |
minProbability | Drop solutions whose probability is below this floor. Default 0.0. |
succeedOnSolutions | true (default): the node succeeds only if the query has at least one solution. false: it succeeds whenever evaluation ran, and you inspect the result yourself. |
Choosing how to reason
The semiring controls how the engine combines confidence as it chains rules:
top-k-proofs(default,k=3) keeps the most likely derivations of each conclusion and ranks solutions by probability. Use it when facts carry probabilities and you want the best-supported answers.min-max-probscores each conclusion by its strongest single derivation. A lighter-weight choice.
If your facts have no probabilities, either mode returns the same crisp yes/no set — the semiring only matters once confidence is in play. minProbability then lets you discard weak conclusions.
Reading the result
The node writes a result object to outputKey. Each solution is a row — a tuple of bound values, with the probability the engine derived for it:
{
"query": "may_auto_merge",
"semiring": { "kind": "top-k-proofs", "k": 3 },
"rows": [{ "probability": 1.0, "tuple": ["pr_482"] }],
"satisfied": true,
"count": 1
}satisfied— whether the query produced at least one solution (above theminProbabilityfloor).rows— one object per solution:tupleholds the bound column values,probabilitythe derived confidence. Rows are sorted most-confident first.count— the number of solutions.
A downstream condition or llm-condition node reads mergeDecision.satisfied to branch — or, with succeedOnSolutions: true, the logic-policy node’s own success/failure drives the tree directly. If a program fails to load or evaluate, the node fails and records the engine’s error message on the blackboard.
Letting the knowledge base shape extraction
A companion node, logic-introspect, closes the loop in the other direction. It reads a rule program and projects its schema — the predicates, their arguments, and the vocabulary they accept — into a form an upstream LLM fact-extraction node can read. That makes the rule program the single source of truth for what facts the model is allowed to produce, so the LLM can’t invent predicates the rules don’t understand.
{
"type": "logic-introspect",
"name": "DescribeKnowledgeBase",
"program": "rel sensitive = {(\"auth\",), (\"billing\",)}\nrel touches_sensitive(c) = touches(c, area) and sensitive(area)",
"outputKey": "kbSchema"
}Both fields are required: program is the rule program to introspect, and outputKey is where the projected schema lands. A typical wiring is logic-introspect → llm-action (which injects kbSchema into its extraction prompt) → logic-policy.
Where rule programs live
Rule programs are authored inline in the program field of the node, inside the agent’s .bt.json tree. Because the tree is an ordinary version-controlled file, policy changes show up in diffs and review like any other code — and the rules sit right next to the agent that reasons with them.
Facts never leak between runs — each node execution starts from a clean engine, seeded only by the program, the inline facts, and whatever factsKey and ruleSelection provide for that tick.