feat: Safe Array Orchestration & Tool Memoization#106
feat: Safe Array Orchestration & Tool Memoization#106
Conversation
|
- Add MemoizeKw token to Chevrotain lexer - Add memoize?: boolean to HandleBinding (tool variant) and ToolDef - Add memoize option to ToolMetadata in bridge-types - Update bridgeWithDecl parser rule to accept optional 'memoize' keyword - Update elementWithDecl to accept 'with <tool> as <handle> [memoize]' - Update toolBlock rule to accept 'memoize' after tool source - Update processLocalBindings to create element-scoped tool instances - Update serializer to handle memoize flag on handles and tool blocks - Add elementScoped marker to HandleBinding for serialization Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
bridge | 2642b66 | Commit Preview URL Branch Preview URL |
Mar 07 2026, 10:00 AM |
- Add memoCache to ExecutionTree, shared across shadow trees - Implement stampede protection by caching Promises (not results) - Extract _callToolCore to separate memoization from instrumentation - Support three-layer memoize resolution: ToolMetadata || ToolDef || HandleBinding - Update ToolLookupContext interface to accept optional memoize flag - Pass memoize flag from scheduler (scheduleToolDef and direct calls) Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
- Inject __memoCache and __callMemo helper in generated code - Track memoized tools from handle bindings and pipe handle entries - Update syncAwareCall to use __callMemo for memoized tools - Support ToolDef-level memoize flag in isMemoized check Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
- Add elementToolWire rule for tool-input wires inside array blocks - Process element-scoped tool wires in AST builder - Fix ToolDef memoize not propagating through merge chain - Update TextMate grammar for memoize keyword - Add 19 comprehensive tests covering parsing, serialization, runtime memoization, element-scoped tools, and compiler parity Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
…round example - Add section on element-scoped tool declarations in array-mapping.mdx - Add section on tool memoization in using-tools-pipes.mdx - Add playground example for tool memoization Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
|
there is a minor bug in how you are determining Look closely at // Memoize if ToolDef or handle binding requests it
const handleMemoize = ctx.bridge?.handles.some(
(h) => h.kind === "tool" && h.memoize && h.name === toolName,
);
const shouldMemoize = toolDef.memoize || handleMemoize;
try {
return await ctx.callTool(toolName, toolDef.fn!, fn, input, shouldMemoize);
}Now look at const promise = (async () => {
// ...
// BUG: You forgot to pass the memoize flag here!
try {
return await ctx.callTool(toolName, toolDef.fn!, fn, input);
}
// ...===== i would want to see a dedicated test suite for variable scoping ... we have auto-isolation wright now and its gets more and more complicated lets create a separate suite that we can validate agains both runtimes. it this is human readable then we can also link to it from the README some examples with multiple nested loops to understand at which level the caching is done and what scoped noes are availble |
- Fix bug: resolveToolDep now passes toolDef.memoize to callTool - Add dedicated scoping.test.ts with 25 tests covering: - Root-level tool reads from inside array blocks - Nested array mapping at 2 and 3 levels deep - Root-level tool visible at all nesting depths - Alias scoping inside array blocks - Pipe transforms inside array blocks - ToolMetadata memoize deduplication - DSL memoize keyword on handle bindings - ToolDef memoize on tool blocks - Request-scoped cache isolation Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Fixed in 0f1a82f:
Tests that involve array mapping + tool reads inside arrays are marked |
|
scoping tests are not good enough ... even if they fail right now we need to have a proper overview of what works ... dont create sjut happy path tests bridge Query.test { o.l1 <- i.a[] as x { the assumption is that every nested inner scope can access outer scope tools (z sees y and x and global, etc). then there is the with tool syntax that explicitly creates a "scoped clone" but i think we also support automatic clone but the tests are not clear about how and when this happens in scoping tests memoization adds not extra value ... unless we want to express at chich level the results re shared ? ===== what happens if the AOT does not supprot a feature? we have a similar pattern in graphql where we have a fallback for the user with an warning message |
…ity coverage - Focus scoping tests on variable/iterator visibility rules, not memoization - Add cross-scope iterator tests (outer iterator from inner block) as TODO documenting expected behavior the parser does not yet support - Add three-level nested array tests (each level + root tool visibility) - Add element-scoped tool isolation tests as TODO documenting expected runtime behavior - Test categories: single-level, two-level, three-level arrays, aliases, pipes, element-scoped tools, request isolation - 36 tests total: 26 pass, 10 todo (0 fail) Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
TODO tests now run the full assertion so they will auto-pass when the feature is implemented. node:test marks failing TODOs with ⚠ and shows the actual error, making the gap clearly visible. Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Rewrote Scoping focus — removed all memoization tests (those stay in
36 tests total: 26 pass, 10 todo with Re: AOT fallback — there's already a pattern in |
==== If an inner scope shadows an outer variable (e.g., If the interpreter relies solely on the string The Secure Fix: Keying by Graph Node, Not NameTo make the cache mathematically secure, we must stop using the human-readable Because your engine already assigns unique Here is the exact change to make the cache strictly adhere to lexical scoping. 1. Update
|
Add element:true to the `to` ref of element-level output wires in processElementLines and processElementScopeLines. This marks wires inside array mapping blocks so the runtime's hasElementWires check correctly detects array mappings that include tool-sourced fields. Previously, wires like `.result <- t.data` (reading from element-scoped tool) had neither from.element nor to.element set, causing the materializer to miss the array mapping and fall back to passthrough. Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Add `elementScope` field to NodeRef for targeting specific nesting
levels. Parser emits `elementScope` (array path prefix) when referencing
outer iterators from inner scopes. Runtime walks parent chain to find
matching shadow tree by `elementScopeKey`.
Example: `o.l1 <- i.a[] as x { .l2 <- x.children[] as y { .xv <- x.v } }`
now correctly reads x.v from the outer scope shadow tree.
Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Use scope-qualified cache keys (root vs element) combined with fnName to prevent cross-scope cache pollution when inner scopes shadow outer tool names. Root-level tools share cache by fnName (deduplication), element-scoped tools (instance >= 100000) use a separate pool. Also refactors callTool signature to accept nodeKey (trunk key) for scope detection and toolName (display name) for tracing, keeping trace output human-readable while the cache is mathematically secure. Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Add BridgeCompilerIncompatibleError for unsupported AST constructs (e.g. elementScope). Compiler's executeBridge catches this error and falls back to the core runtime interpreter with a warning log. Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
Implemented in commits b44a9e6 through 2642b66:
|
Tools declared at root level share global state, causing race conditions when wired from inside array loops. This PR introduces element-scoped tool declarations (
with <tool> as <handle>inside[] as item { }) and request-scoped memoization (memoizekeyword) with Promise-based stampede protection.Parser & Lexer
MemoizeKwtoken; optionalmemoizeonbridgeWithDecl,toolBlock, andelementWithDeclelementToolWirerule for<handle>.<field> = value/<handle>.<field> <- sourceinside array blockswithdeclarations assign fork instance IDs (≥100000), matching existing pipe fork patternzreadsx.vandy.v) viaelementScopeonNodeRefAST Types
memoize?: booleanonHandleBinding(tool variant) andToolDefelementScoped?: booleanonHandleBindingfor serialization routingelementScope?: stringonNodeReffor cross-scope iterator resolutionToolMetadata.memoizeacceptsboolean | { keyFn: (input) => string }for custom cache keysExecution Engine
memoCache = new Map()onExecutionTree, inherited byshadow()(request-scoped, never global)ToolMetadata.memoize || ToolDef.memoize || HandleBinding.memoizeresolveToolDefByNamemerge chain now propagatesmemoizeflagresolveToolDepnow passestoolDef.memoizetocallTool(was missing)callToolacceptsnodeKey(trunk key) for scope-qualified cache keys — root-level tools share cache byfnName(deduplication), element-scoped tools (instance ≥100000) use a separate pool, preventing cross-scope cache pollution when inner scopes shadow outer tool nameselementScopeKeyon shadow trees enablespullSingleto walk the parent chain and resolve outer iterator data from the correct nesting levelCompiler Codegen
__memoCacheMap +__callMemo(fn, input, toolName, keyFn)helper in generated codesyncAwareCalldispatches to__callMemofor tools inmemoizedToolssetBridgeCompilerIncompatibleError: thrown for unsupported AST constructs (e.g.,elementScopecross-scope references)executeBridgecatchesBridgeCompilerIncompatibleErrorand falls back to the core runtime interpreter withlogger.warn()Serializer, Syntax Highlighting, Docs
memoizeon handles, tool blocks, and element-scoped declarationsmemoizeonwithandtoolrulesarray-mapping.mdxandusing-tools-pipes.mdx; playground example addedScoping Test Suite (
scoping.test.ts)Dedicated variable/iterator scoping test suite using shared-parity runner (runtime + AOT) — 38 tests (38 pass, 0 TODO):
zreadsx.vandy.v) — fully working in runtime, compiler falls back viaBridgeCompilerIncompatibleErrorwith myTool as tinside array block, isolation per element — fully workingexecuteBridgecall gets a fresh scopeOriginal prompt
This section details on the original issue you should resolve
<issue_title>Safe Array Orchestration & Tool Memoization</issue_title>
<issue_description>Impact: Solves the N+1 problem for expensive API/DB calls inside arrays, and eliminates race conditions caused by global tool state.
Overview
Currently, tools declared at the root level of a bridge (
with std.httpCall) share a single global state. If a user wires inputs to this tool from inside an array loop ([] as item { ... }), the loop elements overwrite each other's inputs, causing chaotic race conditions.This epic solves this by introducing Element-Scoped Tool Declarations (allowing tools to be instantiated locally per-element) combined with Request-Scoped Memoization (preventing duplicate network calls when multiple elements process the same data).
The Target Developer Experience
Element-Scoped Tool Declarations
Allow users to declare fresh, isolated tool instances inside array mapping blocks.
elementWithDeclrule to acceptwith <tool> as <handle>alongside the existingaliassyntax.processElementLines, intercept these local tool declarations. Assign them a unique instance ID (e.g.,100000 + nextForkSeq++), identically to how Pipe Forks are handled.ExecutionTree.statetrunk, completely preventing input collisions.The
memoizeDSL Keyword & Metadata APIProvide both DSL-level (for generic tools) and TypeScript-level (for native tools) controls for opting into memoization.
ToolMetadata): Expand the.bridgeobject on tool functions to acceptmemoize. This allows tool authors to force memoization by default, and crucially, provide a customkeyFnto bypass the slowJSON.stringifydefault.Implementation Target:
MemoizeKwto the Chevrotain lexer. Allow it at the end ofbridgeWithDecland insidetoolBlockdefinitions.memoize?: booleantoHandleBindingandToolDefinterfaces.||ToolDef||Handle Binding.The Request-Scoped Cache Engine
Implement the actual cache mechanism. Crucial constraint: The cache must live on the request instance, never globally on the Node process, to prevent cross-tenant data leaks.
Interpreter (
ExecutionTree.ts): * AddmemoCache = new Map<string, Promise<any>>()to the root execution tree.Ensure
shadow()explicitly inherits the exact same map reference from the parent.In
callTool, generate the cache key. Ifmeta.memoize.keyFnexists, invoke it; otherwise fallback toJSON.stringify(input).Compiler (
codegen.ts): * Injectconst __memoCache = new Map();at the top of the generated async function.Pass the memoization flag/keyFn into the
__callwrapper to manage the cache lookup.Stampede Protection: The engine must cache the Promise, not the resolved result. This ensures that 100 concurrent array elements requesting the same URL instantly attach
.then()to the exact same Promise, firing only 1 actual HTTP request.Scope rules