BasicFlow tutorial
15. Memory namespaces and model overrides
Two builder calls on a registered step decide what conversation history it sees and which model answers for it. Both are declared in one place, and both are load-bearing.
defineSteps() is where BasicFlow’s non-obvious configuration lives. Every
.useMemory(...) decides whether a step shares a transcript with its neighbours, and
every .useModel(...) decides what it costs and how it reasons. This lesson reads that
list line by line.
The goal
- Share a conversation history between steps, or isolate it.
- Override the flow’s model for one step.
- Understand how step params combine with flow params — and when they do not.
- Read the
gpt-5reasoning example correctly.
The registration list
protected defineSteps(): Step[] {
return [
new WeatherStep(this).useModel({
provider: "openai",
name: "gpt-5",
params: { reasoning: { effort: "low" } },
}),
new NameStep(this).useMemory("default"),
new AddressStep(this).useMemory("default"),
new DOBStep(this).useMemory("default").useModel({
provider: "openai",
name: "gpt-5.1",
params: { reasoning: { effort: "low" } },
}),
new FooLogicStep(this).useMemory("default"),
new GooLogicStep(this).useMemory("default"),
new InContextStep(this).useMemory("separate"),
new ConcurStep1(this),
new ConcurStep2(this),
new ConcurStep3(this),
new ConcurStep4(this),
new PresidentStep(this).useMemory("president"),
new FavoritesStep(this).useMemory("favorite"),
new TerminateSessionStep(this).useMemory("temp"),
];
}
Both builders return this, so they chain in either order.
Memory namespaces
A namespace is a named message array in the flow document. Steps sharing a name share one array; steps with different names cannot see each other’s turns at all.
| Namespace | Steps | Effect |
|---|---|---|
default |
NameStep, AddressStep, DOBStep, FooLogicStep, GooLogicStep |
One continuous profile-collection transcript |
separate |
InContextStep |
The nested movie-idea work is quarantined |
favorite |
FavoritesStep |
The favourites exchange does not enter the profile transcript |
president |
PresidentStep |
The alternate entry point has its own history |
temp |
TerminateSessionStep |
The closing turn does not inherit the collection transcript |
| class name | WeatherStep, ConcurStep1–ConcurStep4 |
Default when .useMemory() is not called |
The default comes from the Step constructor:
protected constructor(flow: Flow) {
this.flow = flow;
this.memorySpace = this.id;
}
so a step that says nothing gets a private namespace named after its class. WeatherStep
is the only main-path step in that position, which is fine — nothing later needs to see
how the city names were negotiated, and the temperatures are in its durable state.
What sharing actually buys
The default group is genuinely sequential conversation. When DOBStep runs, the model
sees the name exchange that NameStep just had. That is why DOBStep’s prompt can say
ask the user to provide the date of birth for {{UserName}} and the reply reads as a
continuation rather than a fresh interrogation.
The cost is context length and cross-talk. Every message in a shared namespace is sent
on every model call for every step using it. A step with a very different role — the
sci-fi movie generator, the favourites collector — inherits instructions and tool
traces that are irrelevant at best and confusing at worst. That is why
InContextStep and FavoritesStep are isolated.
Share a namespace when the steps are stages of one conversation with one persona. Isolate when the step has a different role, a different audience, or runs as a nested child.
Namespace rules
const MEMORY_NAMESPACE_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
const RESERVED_MEMORY_NAMESPACES = new Set([
"__proto__",
"constructor",
"prototype",
]);
useMemory calls assertValidMemoryNamespace immediately, so an invalid name throws in
defineSteps() — before the first request completes. Namespaces become object keys in
the persisted document, which is why the pattern is conservative and the three
prototype-pollution names are reserved.
The nested-execution hazard
Repeating the warning from lesson 13 because
it is a namespace decision, not a runSteps decision: two steps in the same
runSteps([...]) call must not share a namespace. The runner mutates the history array
in place — including overwriting slot 0 with the current step’s system prompt — so
concurrent children on one array interleave and corrupt it. ConcurStep1 through
ConcurStep4 are each on their own class-name namespace, which is what makes BasicFlow’s
fan-out safe.
Model overrides
The flow default is declared once:
protected configModel() {
return {
provider: "openai",
name: "gpt-4o-mini",
params: { temperature: 0.2 },
retryAttempts: 3,
} as const;
}
Two steps override it. WeatherStep gets gpt-5 and DOBStep gets gpt-5.1, both
with reasoning: { effort: "low" }. Everything else inherits gpt-4o-mini.
During bootstrap() the flow walks its steps and fills in the gaps:
for (const [, step] of this.stepMap) {
const name = step.getModel();
if (!name) {
step.inheritModel(this.getModelSelection());
}
if (!step.isLogic()) flowEngine.validateModel(step.getModelSelection());
}
Note the second line: logic steps are skipped, because they never call a model. Everything else is validated against the registered providers on the first request.
The parameter merge rule
This is the subtle part, and the gpt-5 example only works because of it:
export function resolveStepModelSelection(
flow: ResolvedModelSelection,
step?: ResolvedModelSelection,
): ResolvedModelSelection {
if (!step) {
return {
provider: flow.provider,
name: flow.name,
params: merge({}, flow.params),
...(flow.retryAttempts === undefined ? {} : { retryAttempts: flow.retryAttempts }),
};
}
return {
provider: step.provider,
name: step.name,
params:
step.provider === flow.provider && step.name === flow.name
? merge({}, flow.params, step.params)
: merge({}, step.params),
...((step.retryAttempts ?? flow.retryAttempts) === undefined
? {}
: { retryAttempts: step.retryAttempts ?? flow.retryAttempts }),
};
}
Flow params are inherited only when the step selected the same provider and model. A step on a different model starts from an empty parameter set.
retryAttempts is different: it is PicoFlow’s runner policy, not a provider
parameter. A Step inherits the Flow value even when it selects a different
model, unless the Step explicitly provides its own value. BasicFlow sets three
attempts once in configModel(), so its gpt-5 and gpt-5.1 stages use the
same runner policy without inheriting an invalid temperature.
timeoutMs is runner policy too, but it is deliberately not part of the model
selection. BasicFlow declares it with configLlmCallPolicy(), so the same deadline
applies when a step changes provider or model. Unlike retryAttempts, the call policy
is code-owned and is not persisted in the session document. See
lesson 2 for its scope and
override rules.
That is not a stylistic choice. Parameters belong to a model, and gpt-5 does not
accept temperature. The OpenAI adapter says so explicitly:
capabilities: (selection) => ({
temperature: !/^gpt-5(?:$|[-.:])|^o\d/.test(selection.name),
}),
and validateAdapter throws when a temperature is present on a model that reports
temperature: false:
Model 'openai:gpt-5' does not support temperature.
If step params inherited the flow’s temperature: 0.2, WeatherStep and DOBStep
would both fail at bootstrap on every request. Because the model name differs, they get
{ reasoning: { effort: "low" } } and nothing else.
Params are schema-checked
For a built-in provider and model, the params object is parsed by a strict Zod schema before it is stored:
const openAIReasoningSchema = z
.object({
reasoning: z
.object({ effort: z.enum(["minimal", "low", "medium", "high"]).optional() })
.strict()
.optional(),
maxTokens: z.number().int().positive().optional(),
})
.strict();
So effort: "very low" is rejected, and .strict() means writing
{ temperature: 0.2 } alongside gpt-5 is rejected at the schema before it ever
reaches the capability check. Chat models like gpt-4o-mini get a different schema that
accepts temperature and topP but has no reasoning field.
An unknown model on a built-in provider is a hard error:
Unknown built-in model 'openai:gpt-4.7'. Add it to PicoModelCatalog before selecting it.
A model on a custom provider passes through untyped, which is exactly what the NVIDIA adapter from lesson 1 needs:
new FavoritesStep(this)
.useMemory("favorite")
.useModel({
provider: "nvidia",
name: "nvidia/llama-3.3-nemotron-super-49b-v1.5",
params: { temperature: 0.2 },
}),
That block is present but commented out in basic-flow.ts; the active registration is
the plain new FavoritesStep(this).useMemory("favorite"). It is worth reading anyway as
the shape of a custom-provider override.
Persistence and change detection
The resolved selection is written into the flow document, and any step-level override
into that step’s slot. toPersistedModelSelection strips anything that looks like a
credential before writing:
const SENSITIVE_PARAMETER_KEYS = new Set([
"apikey", "api_key", "authorization", "credential",
"credentials", "password", "secret", "token",
]);
so a params object carrying a key does not end up in your session store.
Because the override is persisted per step, a resumed session keeps the model it started
with for that step, even if you have since edited defineSteps().
Why it is written this way
Putting memory and model on the registration line rather than inside the step class is a
deliberate inversion. A step class describes what the stage does; the flow describes
how this deployment runs it. The same NameStep could be registered in a cheap flow on
gpt-4o-mini and in a premium flow on something larger, with no change to the step.
It also gives you one screen that answers the two questions an operator actually asks:
what does this cost, and what can each stage see? Scanning fourteen .useModel and
.useMemory calls is faster than opening fourteen files.
The strictness — Zod-checked params, capability checks, namespace patterns validated on
call — is all front-loaded to bootstrap. A misconfiguration fails on request one, in
development, rather than on the turn that happens to reach DOBStep.
Common mistakes
- Assuming flow params carry over to an overridden model. They do not, unless the
provider and model name are identical. That is the behaviour that makes
gpt-5work. - Setting
temperatureon a reasoning model. Rejected by the strict schema, and by the capability check if it gets past it. - Sharing a namespace between concurrent children. Silent history corruption.
- Sharing a namespace between roles. Every message is resent on every call for every step in that namespace. It costs tokens and it confuses the model.
- Expecting
useMemoryto isolate state. It isolates conversation history only. Step state is always per step, andflow.getStepStatecrosses namespaces freely. - Overriding a model without a registered provider.
validateModelthrows at bootstrap; add the adapter in the module.
Next
WeatherStep has not been examined yet.
16. @Tools batching takes it apart.