@polyweave/bt-harness (1.0.16)
Installation
@polyweave:registry=https://hub.kl1.tenere.ai/api/packages/Polyweave/npm/npm install @polyweave/bt-harness@1.0.16"@polyweave/bt-harness": "1.0.16"About this package
@polyweave/bt-harness
Behavior tree LLM agent harness — tick-stepped FSM with CoC-typed DSL, dual-model loop (agent + evaluator), multi-state blackboard (fact-graph + document + tree), and entity projection system.
Features
- Tick-stepped FSM — Every node is a finite state machine.
harness.sink.write({ type: 'TICK' })advances one step. The scheduler owns timing. - Dual-model loop — Agent executes tasks with tool grants; evaluator independently verifies against checklists. Evaluator never sees the agent's task description.
- Declarative DSL —
bt\...`` tagged template: PARAMETERS, BLACKBOARD, SYMBOLS, POSTCONDITION, CONDITIONS, ACTIONS, TREE. - Multi-state blackboard — Fact-graph + document store + tree store in one transactionally-consistent multi-state store. Entity projection system maps DSL entities to store fields.
- CoC-typed parameters —
SigmaType/BaseType/UnionType;checkValue(),sigmaToJsonSchema(), DESCRIBE serialization. - WAITING state — Nodes wait on preconditions (
when @symbol == value). Composites re-evaluate each tick. External symbol changes unblock WAITING nodes. - Postcondition loop — Root postcondition gates tree completion (video game AI pattern). Tree loops from root until postcondition met. Per-node
untilpostcondition for action-level gating. - Permission system —
reads/writeson actions restrict agent access to blackboard entities and symbols.evaluator_writesrestricts which symbols the evaluator can set. - Timeout — Agent/evaluator deadline timer (default 120s). Emits
bt:node_timed_outon expiry. - Behavior tree — Sequence, Selector, Parallel, Action, Condition nodes with WAITING propagation, forked symbolics per parallel child, snapshot isolation.
- MVCC — Snapshot isolation on forked transactions.
checkConflicts(), commit/abort return status. - CANCEL / ABORT / CLOSE — Per-node abort controllers, timer cleanup, tree reset on cancel.
- Built-in store_write/store_read tools — Document store tools txns thread through the multi-state blackboard. Additional transactional systems (e.g., JJ) layer on top alongside built-in tools.
- validateBtSpec() — Cross-reference validator checks symbols, transitions, preconditions, postconditions, entities, permissions.
Installation
npm install @polyweave/bt-harness
Requires @polyweave/core, @polyweave/state, @polyweave/tools, @polyweave/deepseek, @polyweave/tool-adapter.
Quick Start
import { createBtHarness, createEchoScheduler, bt } from '@polyweave/bt-harness';
var spec = bt`
─── PARAMETERS ───
param cuisine : string = "Roman"
─── BLACKBOARD ───
entity recipe {
content : document
}
─── SYMBOLS ───
@done : boolean = false
─── ACTIONS ───
write_recipe (action):
task "Write a recipe for {{cuisine}} cuisine. store_write('recipe', content)"
tools store_write, store_read
writes recipe.content
evaluator_writes @done
check "Recipe saved to store"
on success: @done = true
─── TREE ───
root = sequence { action write_recipe }
`;
var tools = [
createStoreWriteTool(store),
createStoreReadTool(store)
];
var harness = createBtHarness({
spec,
tools,
apiKey: process.env.DEEPSEEK_API_KEY,
});
harness.source.pipe(createEchoScheduler(harness, createFrameSink(frame => {
if (frame.type === 'bt:tree_complete') console.log('DONE');
if (frame.type === 'bt:tree_failed') console.log('FAILED');
})));
harness.sink.write({ type: 'TICK' });
API
createBtHarness(options)
| Option | Type | Default | Description |
|---|---|---|---|
spec |
string|object |
— | BT DSL text or parsed spec (from bt\...``) |
tree |
object |
— | Raw BT definition (alternative to spec) |
store |
object |
internal | External multi-state store for blackboard (enables cross-harness coordination) |
tools |
Duplex[] |
[] |
Tool duplexes (DESCRIBE-capable). Auto-classified to agent/evaluator sets. |
apiKey |
string |
required | DeepSeek API key |
params |
object |
{} |
Parameter values (matched against spec's SigmaType) |
initialSymbolics |
object |
{} |
Initial symbolic values |
agentFactory |
function |
auto | Custom agent factory { start(prompt) → { collector } } |
evaluatorFactory |
function |
auto | Custom evaluator factory |
agentModel |
string |
'deepseek-v4-flash' |
Model for agent LLM |
evalModel |
string |
'deepseek-v4-flash' |
Model for evaluator LLM |
agentSystem |
string |
built-in | System prompt for agent |
evalSystem |
string |
built-in | System prompt for evaluator |
agentTemperature |
number |
0.3 |
Agent temperature |
evalTemperature |
number |
0.1 |
Evaluator temperature |
agentMaxTokens |
number |
4096 |
Agent max tokens |
evalMaxTokens |
number |
4096 |
Evaluator max tokens |
timeoutMs |
number |
120000 |
Agent/evaluator deadline timer |
maxTicksPerIteration |
number |
1000 |
Max sync loop iterations per tick |
maxTreeLoops |
number |
3 |
Max postcondition-recheck loops before going IDLE |
debug |
boolean |
false |
Enable debug logging |
Returns { source, sink, valid, errors, warnings, symbolics, root, context, tick(), getState(), getWaitingOn(), cancel(), abort(), close() }.
If valid === false, the source is pre-loaded with an ERROR frame and ended. Check harness.valid before starting.
createEchoScheduler(harness, downstreamSink)
Creates a pass-through sink that auto-sends TICK frames on bt:agent_done / bt:eval_done. Frames are forwarded to downstreamSink. Usage:
harness.source.pipe(createEchoScheduler(harness, yourLogSink));
createBtHarnessFromSpec(spec, options)
Convenience wrapper that merges a spec into options:
var harness = createBtHarnessFromSpec(bt\`...\`, { tools, apiKey });
validateBtSpec(spec)
Validates a parsed spec. Returns { valid, errors, warnings }. Checks: tree references, symbol cross-references in conditions/transitions/preconditions/postconditions, entity field references, permission symbol references, SigmaType params.
createBtHarnessDuplex(options)
Wraps a BT spec as a DESCRIBE-capable tool duplex. Responds to DESCRIBE and TOOL_CALL frames. The harness runs once per tool call. Returns { source, sink, _toolName }.
Frame Protocol
Sink (into harness):
| Frame | Effect |
|---|---|
{ type: 'TICK' } |
Advance one step |
{ type: 'CANCEL' } |
Abort controllers, reset tree, emit bt:cancelled |
{ type: 'ABORT' } |
Abort controllers, reset tree, emit bt:aborted, end source |
{ type: 'END' } |
Close all resources, clean up symbolics |
Source (out of harness):
| Frame | Content | Meaning |
|---|---|---|
bt:tree_running |
{} |
Transitioned IDLE → ACTIVE, work starting |
bt:send_agent |
{ nodeId, turn, promptChars } |
Agent prompt sent |
bt:agent_done |
{ nodeId, turn, textChars, toolCalls } |
Agent responded |
bt:send_eval |
{ nodeId, turn } |
Evaluator prompt sent |
bt:eval_done |
{ nodeId, turn } |
Evaluator responded |
bt:node_started |
{ nodeId } |
Node execution began |
bt:node_completed |
{ nodeId, evidence, status } |
Node finished (SUCCESS) |
bt:node_failed |
{ nodeId, evidence } |
Node finished (FAILURE) |
bt:node_retry |
{ nodeId, turn } |
Node retrying |
bt:node_waiting |
{ nodeId, evidence } |
Node is WAITING |
bt:node_loop |
{ nodeId, turn } |
Node postcondition not met, looping |
bt:node_timed_out |
{ nodeId, side, timeoutMs } |
Agent or evaluator deadline expired |
bt:tree_loop |
{ loop } |
Tree completed but root postcondition not met, looping from root |
bt:tree_complete |
{} |
Tree succeeded (root postcondition met) |
bt:tree_failed |
{ reason } |
Tree failed |
bt:symbolic_asserted |
{ name, value } |
Symbol transition applied |
bt:error |
{ code, message } |
Internal error (safety limit, etc.) |
bt:cancelled |
{} |
Harness cancelled |
bt:aborted |
{} |
Harness aborted |
bt:closed |
{} |
Harness closed |
Behavior Tree Node Types
| Type | Behavior |
|---|---|
action |
Agent executes taskDescription; evaluator judges result. Has precondition, until postcondition, onSuccess, onFailure, reads/writes/evaluatorWrites. |
sequence |
Runs children in order. Child WAITING → parent WAITING. Child SUCCESS → advance. Child FAILURE → parent FAILURE. |
selector |
Runs children until SUCCESS. Skips WAITING, tries next. All FAILURE → parent FAILURE. |
parallel |
Runs all children concurrently with forked symbolics (snapshot isolation). All SUCCESS → parent SUCCESS. Any FAILURE → parent FAILURE. |
condition |
Evaluates predicate(symbolics). null/undefined → WAITING; true → SUCCESS; false → FAILURE. Supports >, <, >=, <=, ==, !=. |
DSL Reference
bt\...`` Tagged Template
─── PARAMETERS ───
param name : type [= default]
─── BLACKBOARD ───
entity name {
key : document|fact|tree
}
─── SYMBOLS ───
@name : type = init
─── POSTCONDITION ───
when @symbol == value
when @count >= 3 && @passed == true
─── CONDITIONS ───
name:
when @symbol == value
when @a + @b >= @threshold
─── ACTIONS ───
name (action):
when @symbol == value # precondition — WAITING if not met
until @symbol == value # per-node postcondition — loop until met
task "multiline text with {{param}}, {{@symbol}}, {{entity.field}}"
tools toolA, toolB
check "checklist item" # quoted → LLM evaluator
check called('toolA') # unquoted → Jessie expression
on success: @symbol = @a + @b # Jessie expression RHS
on failure: @symbol = value
reads entity.key
reads @symbol
writes entity.key
writes @symbol
evaluator_writes @sym1, @sym2
─── TREE ───
root = sequence {
selector "id" {
sequence { condition gate, action fix }
action default
}
parallel "id" { action a, action b }
}
Interpolation
| Syntax | Resolves from | Example |
|---|---|---|
{{param}} |
Action parameters | {{cuisine}} → "Roman" |
{{@symbol}} |
Symbol current value | {{@step}} → 2 |
{{entity.field}} |
Entity projection | {{recipe.content}} → "..." |
{{expr}} |
Any Jessie expression | {{@total / max(@count, 1)}} |
All {{...}} interpolation is compiled through Jessie. @ distinguishes symbols from params. Bare identifiers without @ resolve to params, then symbols.
{{@symbol}} resolves via symbolics.getValue(). {{entity.key}} resolves via the multi-state's entity projection system — it queries the appropriate store (document, fact-graph, or tree) based on the entity's BLACKBOARD definition.
Parameter types
string, number, boolean, null, or union "A" | "B" | "C".
Transition operators
on success: @symbol = expression — any Jessie expression evaluated against the symbol scope. Value is asserted on the target symbol. Only applied if the action's writes permission includes the symbol.
Predicates (when/until/conditions)
when @symbol == value (comparison sugar) or when expr (raw Jessie). Full JS expression subset — logical operators, arithmetic, FFI calls, property access. All predicates compile through Jessie implicitly.
Permissions
Three-tier access control on actions:
| Declaration | Enforces |
|---|---|
reads entity.key |
Only these entities/fields appear in prompt context |
reads @symbol |
Agent prompt includes this symbol's value |
writes entity.key |
Agent tools can write to this entity |
writes @symbol |
Transitions (on success:/on failure:) only apply for declared symbols |
evaluator_writes @sym1, @sym2 |
Evaluator's symbolicUpdates filtered to these symbols only |
If writes is empty/absent, all transitions are allowed (backward compatible). If evaluator_writes is empty/absent, all evaluator symbol updates are allowed.
Architecture
Blackboard (multi-state store)
Every harness creates a multi-state store with three sub-stores:
createMultiStateStore({
graphStore: createFactGraph(), → symbolic state (key-value)
docStore: createDocumentStore(), → document sections (structured content)
treeStore: createTreeStore() → tree nodes (hierarchical data)
})
All three share the same transaction. TXN_BEGIN/COMMIT/ABORT propagate atomically across all stores. A forked parallel child's writes to documents, facts, and tree nodes commit or abort together.
Built-in tools vs. external transactional systems
The built-in store_write/store_read tools write to the blackboard's document store. Additional transactional systems (e.g., JJ, encyclopedia, custom state machines) can layer on top via custom tool duplexes. These external systems operate on their own stores but share the blackboard's entity namespace for interoperability.
Tick-stepped execution — unified clock
Every node and every tool is a push-stream duplex. One clock drives everything. Ticks flow down through tools to nested harnesses. Frames flow up through source chains. The top-level scheduler owns the rhythm.
Top-level scheduler
│
├─ TICK → parent harness sink
│ ├─ handleInput: forward TICK to all tools
│ ├─ tickHarness(): advance one step
│ │ ├─ condition → evaluate synchronously → advance tree
│ │ ├─ action → start agent/evaluator → AWAIT → yield
│ │ └─ node completed → advance to next sibling, continue
│ ├─ TICK forwarded to tools:
│ │ ├─ simple tool → no-op (accept and ignore)
│ │ └─ BT harness tool → forward to internal harness sink
│ │ └─ internal tickHarness() → advance one step
│ └─ post-tree postcondition: if not met → bt:tree_loop → re-enter
│
└─ Source frames flow outward from all levels
├─ child frames → tee'd to tool's source → parent's source → observer
└─ observer sees bt:agent_done → sends next TICK
Each TICK advances every harness in the tree by one step. Deeply nested BT harnesses (harness → tool → harness → tool → ...) all step together on the same clock.
Tick propagation through tools
- Every tool accepts TICK on its sink.
createToolDuplextools ignore TICK as a no-op.createBtHarnessDuplextools forward TICK to their internal harness. - The harness forwards TICK to all tools on every received tick (
handleInputatbt-harness.js:1032). This ensures tools receive ticks even when the parent harness is in AWAIT state (blocked on an agent call). - Frames flow outward: BT harness tools tee their internal harness's source to the tool's external source. Every frame from deeply nested harnesses is visible at the top level.
- CANCEL/ABORT propagate: Parent forwards CANCEL/ABORT to all tools. BT harness tools forward to internal harness. Simple tools ignore.
Legacy: Echo scheduler
For standalone single-harness scripts, createEchoScheduler provides a simple auto-tick wrapper. It watches for bt:agent_done/bt:eval_done and re-ticks. For nested/composed harnesses, prefer the unified clock model above.
Harness State
| State | Meaning |
|---|---|
IDLE |
No work in progress — waiting for TICK or external symbol change |
ACTIVE |
Work in progress (agent/evaluator call in flight) |
SUCCESS |
Behavior tree completed |
FAILURE |
Behavior tree failed |
ERROR |
Spec validation failed (harness created with valid: false) |
Exports
| Export | Description |
|---|---|
createBtHarness |
Tick-stepped harness (canonical) |
createBtHarnessDuplex |
BT spec as DESCRIBE-capable tool duplex |
createBtHarnessFromSpec |
Convenience wrapper (spec + options) |
createEchoScheduler |
(deprecated) Pass-through sink that auto-TICKs for standalone scripts |
bt |
Tagged template literal for DSL |
parseBtSpec |
Parse DSL text → { params, entities, symbols, tree, ... } |
validateBtSpec |
Cross-reference validator → { valid, errors, warnings } |
createSymbolicState(initial, store?, entities?) |
Multi-state-backed symbolic state |
entitiesToProjections |
Map DSL entities → multi-state projection config |
createBaseType, createUnionType, createSigmaType |
CoC type constructors |
buildRecordType, checkValue, applyDefaults |
Type helpers |
sigmaToJsonSchema, typeToJSON, typeFromJSON |
Type serialization |
createBehaviorTree, tickTree, resetTree, snapshotTree |
BT tree operations |
findActiveNode |
Find next active node in tree |
NodeType, NodeStatus, FsmState |
Enum constants |
createToolCycleLimiter, createCollector |
Tool utilities |
createAgentFactory, createEvaluatorFactory |
Session factories |
Examples
In-tree (correct usage)
| File | Description |
|---|---|
recipe-pipeline.js |
Pipeline — two nodes, selector, condition gate, transactional state, retry loop |
fusion-menu-bt.js |
Complex BT — 9 actions, 4 selectors, parameter interpolation, symbolic routing |
advanced-patterns.js |
PARALLEL + POSTCONDITION + echo scheduler — two concurrent agents, postcondition gate |
conflict-resolution.js |
Conflict as a BT condition — parallel writers collide, detector finds conflicts, resolver merges |
document-store-tools.js |
Library — DESCRIBE-capable store write/read tool duplexes (txn-threaded) |
unified-tick-nesting.js |
Unified clock — parent harness calls child BT harness as tool, single tick loop drives both |
nested-harnesses.js |
Composition — FD refine tool used by summary BT, content extraction from child harness |
Real-world packages (to be refactored)
| Package | Issue | Guide |
|---|---|---|
@polyweave/grpo |
External frame counting replaces POSTCONDITION + PARALLEL | ../grpo/SUGGESTION.md |
@polyweave/feedback-descent |
External stagnation tracking replaces CONDITIONS + SYMBOLS | ../feedback-descent/SUGGESTION.md |
Packages with BT-based alternatives available
| Package | BT replacement | File |
|---|---|---|
@polyweave/state-search |
createTreeSearchHarness — recursive tree traversal as POSTCONDITION loop |
../state-search/src/tree-search-harness.js |
@polyweave/raptor |
createSummaryRefinerBt — summary refinement with local scoring, no LLM loop |
../raptor/src/summary-refiner-bt.js |
Run examples:
node examples/recipe-pipeline.js
node examples/fusion-menu-bt.js
node examples/advanced-patterns.js
Anti-pattern: External frame counting
A common mistake is to count bt:node_completed / bt:node_failed frames from outside the tree and manually track iteration state — cancelling and re-ticking the harness in an external loop. This bypasses the BT's own control-flow system entirely.
Don't do this:
// ANTI-PATTERN — fragile, har to maintain, duplicates BT features
var iteration = 0, nodeDone = 0;
harness.source.pipe(listener(frame => {
if (frame.type === 'bt:node_completed' || frame.type === 'bt:node_failed') {
nodeDone++;
if (nodeDone >= 2) { // ← hard-coded coupling to tree structure
nodeDone = 0;
iteration++;
if (iteration >= max) {
harness.symbolics.assertValue('terminate', true);
harness.sink.write(TICK);
return;
}
harness.cancel(); // ← unnecessary reset
harness.sink.write(TICK);
}
}
}));
Do this instead — push control flow into the DS L:
// SYMBOLS track iteration natively
// POSTCONDITION gates termination: when @iteration >= @max
// on success: @iteration = @iteration + 1 transitions advance state
// SELECTOR fallthrough re-enters the tree naturally
harness.source.pipe(createEchoScheduler(harness, logSink));
harness.sink.write(TICK);
// Done. No frame counting. No cancel/retick. The tree owns scheduling.
Symptoms you're doing it wrong:
- Counting
bt:node_completedframes to decide when to advance - Calling
harness.cancel()between iterations - Setting
@terminatefrom outside the tree - Disabling
maxTreeLoops: 0 - Tracking epoch/index/etc. in JS variables mirroring
@symbols - Reading the document store directly instead of checking symbols
Packages that currently do this: @polyweave/grpo, @polyweave/feedback-descent. See their SUGGESTION.md for how to fix.
Running Tests
Tests
node --test test/*.test.js
93 tests, 92 pass (1 flaky fake-adapter test under concurrency).
Requirements
Node.js 18 or later. ESM only.
Depends on @polyweave/core, @polyweave/state, @polyweave/tools, @polyweave/tool-adapter.
License
MIT
Dependencies
Dependencies
| ID | Version |
|---|---|
| @polyweave/harness-base | * |
| @polyweave/state | * |
| @polyweave/tool-adapter | * |
| subscript | ^10.4.13 |
Development Dependencies
| ID | Version |
|---|---|
| @polyweave/tools | * |
| @rigor/core | * |
Peer Dependencies
| ID | Version |
|---|---|
| @polyweave/core | * |