Let a nested behavior without a start node run from On start (#230)

A behavior built as a plain behavior, with On start and no Behavior
start, did nothing when another behavior called it through Run
behavior. The node only showed a warning. Such a behavior should run
as a call too, without the author having to rebuild its entry.

- New callEntries() in nested.ts returns the Behavior start node or,
  when there is none, every On start node. callProblem uses it too.
- GraphInterpreter.call() follows all entries one after the other,
  in graph order, in the same frame and with the same variables.
- Behavior end still returns what reaches it. A call through On start
  has no inputs, because only the start node declares them.
- It is a fallback only. With a Behavior start present, On start
  nodes stay out of the call.
- The node warning now reads "No start or On start node - does
  nothing" and only shows when neither exists.
- Keep and local state machines behave as before.

Tests:
- ./check.sh: 163 files, 2396 tests, CHECK EXIT 0 (before 2392).
- 4 new unit tests in tests/core/nested-behavior.test.ts: the flow
  after On start runs, the return value arrives, several On start
  nodes run in graph order, a start node keeps On start out.
- e2e 19: ALL PASSED, 35 checks. Only the expected warning text
  changed, the check sum stays at 1385.
- Counter-checks: without the fallback 3 tests fail. With only the
  first On start exactly the order test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-25 16:13:14 +02:00
1 parent 454d202109
commit 21d7149756
5 files changed
+117 -33

No files matched your search

+27 -26
View File
@@ -1,6 +1,6 @@
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import { type GraphLookup, NO_LOOKUP } from './lookup';
import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested';
import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested';
import type { PropertyBag } from '../model/Properties';
import type { NodeContext } from './NodeContext';
import {
@@ -159,46 +159,47 @@ export class GraphInterpreter {
}
/**
* Runs another behavior from its start node to its end node and returns what the end node was
* handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is
* a call and not an inlining: nothing of it survives the return but the values it hands back –
* and the variables it keeps, which wait for the next call from the same site.
* Runs another behavior from its start node (or, lacking one, its `On start` nodes) to its end
* node and returns what the end node was handed. The nested graph gets its own frame and a fresh
* copy of its variable defaults, so it is a call and not an inlining: nothing of it survives the
* return but the values it hands back – and the variables it keeps, which wait for the next call
* from the same site.
*/
private call(call: CallResult, site: string, ctx: NodeContext, depth: number): Record<string, Value> {
const program = this.lookup.behavior(call.behaviorId);
if (!program) return {};
if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`);
const start = terminalNode(program.graph, BEHAVIOR_START);
if (!start) return {};
const entries = callEntries(program.graph);
if (entries.length === 0) return {};
const sites = [...this.callSites, site].join('/');
const kept = program.kept ?? [];
const variables = resumedVariables(program.defaults, this.keptCalls.get(sites), kept);
if (kept.length > 0) this.keptCalls.set(sites, variables);
const outer = this.frame;
this.frame = emptyFrame(program.graph);
this.frame.outputCache.set(start.id, { ...call.inputs });
this.record(start.id, call.inputs);
for (const entry of entries) {
this.frame.outputCache.set(entry.id, { ...call.inputs });
this.record(entry.id, call.inputs);
}
this.callStack.push(call.behaviorId);
this.callSites.push(site);
this.calledBehaviors.add(call.behaviorId);
// Fresh states every call, and nothing of the caller's variables or states. What it does to a
// local machine therefore dies with the call and is not reported; a `global` machine is the
// run's, so moving that one is a move like any other.
const inner: NodeContext = {
...ctx,
variables,
states: {},
stateChanged: (machineId, from, to) => {
if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to);
},
};
try {
this.trace?.onNode(start.id);
// Fresh states every call, and nothing of the caller's variables or states. What it does to a
// local machine therefore dies with the call and is not reported; a `global` machine is the
// run's, so moving that one is a move like any other.
this.follow(
start,
'exec',
{
...ctx,
variables,
states: {},
stateChanged: (machineId, from, to) => {
if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to);
},
},
depth + 1,
);
for (const entry of entries) {
this.trace?.onNode(entry.id);
this.follow(entry, 'exec', inner, depth + 1);
}
const end = terminalNode(program.graph, BEHAVIOR_END);
return (end && this.frame.outputCache.get(end.id)) ?? {};
} finally {
+11 -1
View File
@@ -20,6 +20,7 @@ export const BEHAVIOR_END = 'BehaviorEnd';
export const RUN_BEHAVIOR = 'RunBehavior';
export const VARIABLES = 'Variables';
export const PROPERTIES = 'Properties';
export const ON_START = 'OnStart';
export const BEHAVIOR_PARAM = 'behavior';
export const VARIABLES_PARAM = 'variablesAsset';
@@ -92,6 +93,15 @@ export function terminalNode(graph: Graph, type: string): GraphNode | undefined
return graph.nodes.find((n) => n.type === type);
}
/**
* Where a call into `graph` begins: its start node, or – for a behavior that has none – every
* `On start` in it, in graph order, as when the behavior starts on its own (#230).
*/
export function callEntries(graph: Graph): GraphNode[] {
const start = terminalNode(graph, BEHAVIOR_START);
return start ? [start] : graph.nodes.filter((n) => n.type === ON_START);
}
export function isTerminalType(type: string): boolean {
return type === BEHAVIOR_START || type === BEHAVIOR_END;
}
@@ -267,7 +277,7 @@ export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''):
if (id === ownerId) return 'A behavior cannot run itself';
const program = lookup.behavior(id);
if (!program) return 'That behavior is gone';
if (!terminalNode(program.graph, BEHAVIOR_START)) return 'No start node – does nothing';
if (callEntries(program.graph).length === 0) return 'No start or On start node – does nothing';
return null;
}
+5 -4
View File
@@ -4,16 +4,17 @@ import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, declaredPorts, RUN_BEHAVI
import { exec } from '../shared';
/**
* Runs another behavior's graph in place, from its start node to its end node. Its ports are those
* the named behavior declares; the interpreter does the call, because only it can hold a second
* graph's pulse.
* Runs another behavior's graph in place, from its start node (or its `On start` nodes, #230) to
* its end node. Its ports are those the named behavior declares; the interpreter does the call,
* because only it can hold a second graph's pulse.
*/
export const RunBehavior: NodeDef = {
type: RUN_BEHAVIOR,
label: 'Run behavior',
category: 'flow',
description:
'Runs another behavior as a step of this one, passing values in and taking results out. A ' +
'Runs another behavior as a step of this one, passing values in and taking results out. It ' +
'begins at the Behavior start node, or at On start if that behavior has none. A ' +
'variable marked Keep in that behavior carries on from the last call of this node on this ' +
'object; the others start at their defaults every time.',
inputs: [exec()],