@@ -297,6 +297,10 @@ class CodegenContext {
297297 private toolDepVars = new Map < string , string > ( ) ;
298298 /** Sparse fieldset filter for output wire pruning. */
299299 private requestedFields : string [ ] | undefined ;
300+ /** Per tool signature cursor used to assign distinct wire instances to repeated handle bindings. */
301+ private toolInstanceCursors = new Map < string , number > ( ) ;
302+ /** Tool trunk keys declared with `memoize`. */
303+ private memoizedToolKeys = new Set < string > ( ) ;
300304
301305 constructor (
302306 bridge : Bridge ,
@@ -365,11 +369,14 @@ class CodegenContext {
365369 break ;
366370 }
367371 }
368- const instance = this . findInstance ( module , refType , fieldName ) ;
372+ const instance = this . findNextInstance ( module , refType , fieldName ) ;
369373 const tk = `${ module } :${ refType } :${ fieldName } :${ instance } ` ;
370374 const vn = `_t${ ++ this . toolCounter } ` ;
371375 this . varMap . set ( tk , vn ) ;
372376 this . tools . set ( tk , { trunkKey : tk , toolName : h . name , varName : vn } ) ;
377+ if ( h . memoize ) {
378+ this . memoizedToolKeys . add ( tk ) ;
379+ }
373380 break ;
374381 }
375382 }
@@ -435,25 +442,36 @@ class CodegenContext {
435442 }
436443
437444 /** Find the instance number for a tool from the wires. */
438- private findInstance ( module : string , type : string , field : string ) : number {
445+ private findNextInstance ( module : string , type : string , field : string ) : number {
446+ const sig = `${ module } :${ type } :${ field } ` ;
447+ const instances : number [ ] = [ ] ;
439448 for ( const w of this . bridge . wires ) {
440449 if (
441450 w . to . module === module &&
442451 w . to . type === type &&
443452 w . to . field === field &&
444453 w . to . instance != null
445454 )
446- return w . to . instance ;
455+ instances . push ( w . to . instance ) ;
447456 if (
448457 "from" in w &&
449458 w . from . module === module &&
450459 w . from . type === type &&
451460 w . from . field === field &&
452461 w . from . instance != null
453462 )
454- return w . from . instance ;
455- }
456- return 1 ;
463+ instances . push ( w . from . instance ) ;
464+ }
465+ const uniqueInstances = [ ...new Set ( instances ) ] . sort ( ( a , b ) => a - b ) ;
466+ const nextIndex = this . toolInstanceCursors . get ( sig ) ?? 0 ;
467+ this . toolInstanceCursors . set ( sig , nextIndex + 1 ) ;
468+ if ( uniqueInstances [ nextIndex ] != null ) return uniqueInstances [ nextIndex ] ! ;
469+ const lastInstance = uniqueInstances . at ( - 1 ) ?? 0 ;
470+ // Some repeated handle bindings are never referenced in wires (for example,
471+ // an unused shadowed tool alias in a nested loop). In that case we still
472+ // need a distinct synthetic instance number so later bindings don't collide
473+ // with earlier tool registrations.
474+ return lastInstance + ( nextIndex - uniqueInstances . length ) + 1 ;
457475 }
458476
459477 // ── Main compilation entry point ──────────────────────────────────────────
@@ -729,6 +747,59 @@ class CodegenContext {
729747 lines . push ( ` throw err;` ) ;
730748 lines . push ( ` }` ) ;
731749 lines . push ( ` }` ) ;
750+ if ( this . memoizedToolKeys . size > 0 ) {
751+ lines . push ( ` const __toolMemoCache = new Map();` ) ;
752+ lines . push ( ` function __stableMemoizeKey(value) {` ) ;
753+ lines . push ( ` if (value === undefined) return "undefined";` ) ;
754+ lines . push ( ' if (typeof value === "bigint") return `${value}n`;' ) ;
755+ lines . push (
756+ ` if (value === null || typeof value !== "object") { const serialized = JSON.stringify(value); return serialized ?? String(value); }` ,
757+ ) ;
758+ lines . push ( ` if (Array.isArray(value)) {` ) ;
759+ lines . push (
760+ ' return `[${value.map((item) => __stableMemoizeKey(item)).join(",")}]`;' ,
761+ ) ;
762+ lines . push ( ` }` ) ;
763+ lines . push (
764+ ` const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));` ,
765+ ) ;
766+ lines . push (
767+ ' return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${__stableMemoizeKey(entryValue)}`).join(",")}}`;' ,
768+ ) ;
769+ lines . push ( ` }` ) ;
770+ lines . push (
771+ ` function __callMemoized(fn, input, toolName, memoizeKey) {` ,
772+ ) ;
773+ lines . push ( ` let toolCache = __toolMemoCache.get(memoizeKey);` ) ;
774+ lines . push ( ` if (!toolCache) {` ) ;
775+ lines . push ( ` toolCache = new Map();` ) ;
776+ lines . push ( ` __toolMemoCache.set(memoizeKey, toolCache);` ) ;
777+ lines . push ( ` }` ) ;
778+ lines . push ( ` const cacheKey = __stableMemoizeKey(input);` ) ;
779+ lines . push ( ` const cached = toolCache.get(cacheKey);` ) ;
780+ lines . push ( ` if (cached !== undefined) return cached;` ) ;
781+ lines . push ( ` try {` ) ;
782+ lines . push (
783+ ` const result = fn.bridge?.sync ? __callSync(fn, input, toolName) : __call(fn, input, toolName);` ,
784+ ) ;
785+ lines . push ( ` if (result && typeof result.then === "function") {` ) ;
786+ lines . push (
787+ ` const pending = Promise.resolve(result).catch((error) => {` ,
788+ ) ;
789+ lines . push ( ` toolCache.delete(cacheKey);` ) ;
790+ lines . push ( ` throw error;` ) ;
791+ lines . push ( ` });` ) ;
792+ lines . push ( ` toolCache.set(cacheKey, pending);` ) ;
793+ lines . push ( ` return pending;` ) ;
794+ lines . push ( ` }` ) ;
795+ lines . push ( ` toolCache.set(cacheKey, result);` ) ;
796+ lines . push ( ` return result;` ) ;
797+ lines . push ( ` } catch (error) {` ) ;
798+ lines . push ( ` toolCache.delete(cacheKey);` ) ;
799+ lines . push ( ` throw error;` ) ;
800+ lines . push ( ` }` ) ;
801+ lines . push ( ` }` ) ;
802+ }
732803
733804 // ── Dead tool detection ────────────────────────────────────────────
734805 // Detect which tools are reachable from the (possibly filtered) output
@@ -938,19 +1009,33 @@ class CodegenContext {
9381009 * Generate a tool call expression that uses __callSync for sync tools at runtime,
9391010 * falling back to `await __call` for async tools. Used at individual call sites.
9401011 */
941- private syncAwareCall ( fnName : string , inputObj : string ) : string {
1012+ private syncAwareCall (
1013+ fnName : string ,
1014+ inputObj : string ,
1015+ memoizeTrunkKey ?: string ,
1016+ ) : string {
9421017 const fn = `tools[${ JSON . stringify ( fnName ) } ]` ;
9431018 const name = JSON . stringify ( fnName ) ;
1019+ if ( memoizeTrunkKey && this . memoizedToolKeys . has ( memoizeTrunkKey ) ) {
1020+ return `await __callMemoized(${ fn } , ${ inputObj } , ${ name } , ${ JSON . stringify ( memoizeTrunkKey ) } )` ;
1021+ }
9441022 return `(${ fn } .bridge?.sync ? __callSync(${ fn } , ${ inputObj } , ${ name } ) : await __call(${ fn } , ${ inputObj } , ${ name } ))` ;
9451023 }
9461024
9471025 /**
9481026 * Same as syncAwareCall but without await — for use inside Promise.all() and
9491027 * in sync array map bodies. Returns a value for sync tools, a Promise for async.
9501028 */
951- private syncAwareCallNoAwait ( fnName : string , inputObj : string ) : string {
1029+ private syncAwareCallNoAwait (
1030+ fnName : string ,
1031+ inputObj : string ,
1032+ memoizeTrunkKey ?: string ,
1033+ ) : string {
9521034 const fn = `tools[${ JSON . stringify ( fnName ) } ]` ;
9531035 const name = JSON . stringify ( fnName ) ;
1036+ if ( memoizeTrunkKey && this . memoizedToolKeys . has ( memoizeTrunkKey ) ) {
1037+ return `__callMemoized(${ fn } , ${ inputObj } , ${ name } , ${ JSON . stringify ( memoizeTrunkKey ) } )` ;
1038+ }
9541039 return `(${ fn } .bridge?.sync ? __callSync(${ fn } , ${ inputObj } , ${ name } ) : __call(${ fn } , ${ inputObj } , ${ name } ))` ;
9551040 }
9561041
@@ -986,18 +1071,18 @@ class CodegenContext {
9861071 ) ;
9871072 if ( mode === "fire-and-forget" ) {
9881073 lines . push (
989- ` try { ${ this . syncAwareCall ( tool . toolName , inputObj ) } ; } catch (_e) {}` ,
1074+ ` try { ${ this . syncAwareCall ( tool . toolName , inputObj , tool . trunkKey ) } ; } catch (_e) {}` ,
9901075 ) ;
9911076 lines . push ( ` const ${ tool . varName } = undefined;` ) ;
9921077 } else if ( mode === "catch-guarded" ) {
9931078 // Catch-guarded: store result AND the actual error so unguarded wires can re-throw.
9941079 lines . push ( ` let ${ tool . varName } , ${ tool . varName } _err;` ) ;
9951080 lines . push (
996- ` try { ${ tool . varName } = ${ this . syncAwareCall ( tool . toolName , inputObj ) } ; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${ tool . varName } _err = _e; }` ,
1081+ ` try { ${ tool . varName } = ${ this . syncAwareCall ( tool . toolName , inputObj , tool . trunkKey ) } ; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${ tool . varName } _err = _e; }` ,
9971082 ) ;
9981083 } else {
9991084 lines . push (
1000- ` const ${ tool . varName } = ${ this . syncAwareCall ( tool . toolName , inputObj ) } ;` ,
1085+ ` const ${ tool . varName } = ${ this . syncAwareCall ( tool . toolName , inputObj , tool . trunkKey ) } ;` ,
10011086 ) ;
10021087 }
10031088 return ;
@@ -1070,7 +1155,7 @@ class CodegenContext {
10701155 lines . push ( ` let ${ tool . varName } ;` ) ;
10711156 lines . push ( ` try {` ) ;
10721157 lines . push (
1073- ` ${ tool . varName } = ${ this . syncAwareCall ( fnName , inputObj ) } ;` ,
1158+ ` ${ tool . varName } = ${ this . syncAwareCall ( fnName , inputObj , tool . trunkKey ) } ;` ,
10741159 ) ;
10751160 lines . push ( ` } catch (_e) {` ) ;
10761161 if ( "value" in onErrorWire ) {
@@ -1087,18 +1172,18 @@ class CodegenContext {
10871172 lines . push ( ` }` ) ;
10881173 } else if ( mode === "fire-and-forget" ) {
10891174 lines . push (
1090- ` try { ${ this . syncAwareCall ( fnName , inputObj ) } ; } catch (_e) {}` ,
1175+ ` try { ${ this . syncAwareCall ( fnName , inputObj , tool . trunkKey ) } ; } catch (_e) {}` ,
10911176 ) ;
10921177 lines . push ( ` const ${ tool . varName } = undefined;` ) ;
10931178 } else if ( mode === "catch-guarded" ) {
10941179 // Catch-guarded: store result AND the actual error so unguarded wires can re-throw.
10951180 lines . push ( ` let ${ tool . varName } , ${ tool . varName } _err;` ) ;
10961181 lines . push (
1097- ` try { ${ tool . varName } = ${ this . syncAwareCall ( fnName , inputObj ) } ; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${ tool . varName } _err = _e; }` ,
1182+ ` try { ${ tool . varName } = ${ this . syncAwareCall ( fnName , inputObj , tool . trunkKey ) } ; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${ tool . varName } _err = _e; }` ,
10981183 ) ;
10991184 } else {
11001185 lines . push (
1101- ` const ${ tool . varName } = ${ this . syncAwareCall ( fnName , inputObj ) } ;` ,
1186+ ` const ${ tool . varName } = ${ this . syncAwareCall ( fnName , inputObj , tool . trunkKey ) } ;` ,
11021187 ) ;
11031188 }
11041189 }
@@ -1190,7 +1275,7 @@ class CodegenContext {
11901275 4 ,
11911276 ) ;
11921277 lines . push (
1193- ` const ${ tool . varName } = ${ this . syncAwareCall ( tool . toolName , inputObj ) } ;` ,
1278+ ` const ${ tool . varName } = ${ this . syncAwareCall ( tool . toolName , inputObj , tool . trunkKey ) } ;` ,
11941279 ) ;
11951280 return ;
11961281 }
@@ -2385,7 +2470,9 @@ class CodegenContext {
23852470 // Non-internal tool in element scope — inline as an await __call
23862471 const inputObj = this . buildElementToolInput ( toolWires , elVar ) ;
23872472 const fnName = this . resolveToolDef ( tool . toolName ) ?. fn ?? tool . toolName ;
2388- return `await __call(tools[${ JSON . stringify ( fnName ) } ], ${ inputObj } , ${ JSON . stringify ( fnName ) } )` ;
2473+ return this . memoizedToolKeys . has ( trunkKey )
2474+ ? `await __callMemoized(tools[${ JSON . stringify ( fnName ) } ], ${ inputObj } , ${ JSON . stringify ( fnName ) } , ${ JSON . stringify ( trunkKey ) } )`
2475+ : `await __call(tools[${ JSON . stringify ( fnName ) } ], ${ inputObj } , ${ JSON . stringify ( fnName ) } )` ;
23892476 }
23902477
23912478 /**
@@ -2571,11 +2658,19 @@ class CodegenContext {
25712658 const fnName = this . resolveToolDef ( tool . toolName ) ?. fn ?? tool . toolName ;
25722659 if ( syncOnly ) {
25732660 const fn = `tools[${ JSON . stringify ( fnName ) } ]` ;
2661+ if ( this . memoizedToolKeys . has ( tk ) ) {
2662+ lines . push (
2663+ `const ${ vn } = __callMemoized(${ fn } , ${ inputObj } , ${ JSON . stringify ( fnName ) } , ${ JSON . stringify ( tk ) } );` ,
2664+ ) ;
2665+ } else {
2666+ lines . push (
2667+ `const ${ vn } = __callSync(${ fn } , ${ inputObj } , ${ JSON . stringify ( fnName ) } );` ,
2668+ ) ;
2669+ }
2670+ } else {
25742671 lines . push (
2575- `const ${ vn } = __callSync( ${ fn } , ${ inputObj } , ${ JSON . stringify ( fnName ) } ) ;` ,
2672+ `const ${ vn } = ${ this . syncAwareCall ( fnName , inputObj , tk ) } ;` ,
25762673 ) ;
2577- } else {
2578- lines . push ( `const ${ vn } = ${ this . syncAwareCall ( fnName , inputObj ) } ;` ) ;
25792674 }
25802675 }
25812676 }
@@ -2919,7 +3014,9 @@ class CodegenContext {
29193014 inputObj = this . buildObjectLiteral ( toolWires , ( w ) => w . to . path , 4 ) ;
29203015 }
29213016
2922- let expr = `(await __call(tools[${ JSON . stringify ( fnName ) } ], ${ inputObj } , ${ JSON . stringify ( fnName ) } ))` ;
3017+ let expr = this . memoizedToolKeys . has ( key )
3018+ ? `(await __callMemoized(tools[${ JSON . stringify ( fnName ) } ], ${ inputObj } , ${ JSON . stringify ( fnName ) } , ${ JSON . stringify ( key ) } ))`
3019+ : `(await __call(tools[${ JSON . stringify ( fnName ) } ], ${ inputObj } , ${ JSON . stringify ( fnName ) } ))` ;
29233020 if ( ref . path . length > 0 ) {
29243021 expr =
29253022 expr + ref . path . map ( ( p ) => `?.[${ JSON . stringify ( p ) } ]` ) . join ( "" ) ;
@@ -3349,7 +3446,7 @@ class CodegenContext {
33493446 ( w ) => w . to . path ,
33503447 4 ,
33513448 ) ;
3352- return this . syncAwareCallNoAwait ( tool . toolName , inputObj ) ;
3449+ return this . syncAwareCallNoAwait ( tool . toolName , inputObj , tool . trunkKey ) ;
33533450 }
33543451
33553452 const fnName = toolDef . fn ?? tool . toolName ;
@@ -3384,7 +3481,7 @@ class CodegenContext {
33843481 const inputParts = [ ...inputEntries . values ( ) ] ;
33853482 const inputObj =
33863483 inputParts . length > 0 ? `{\n${ inputParts . join ( ",\n" ) } ,\n }` : "{}" ;
3387- return this . syncAwareCallNoAwait ( fnName , inputObj ) ;
3484+ return this . syncAwareCallNoAwait ( fnName , inputObj , tool . trunkKey ) ;
33883485 }
33893486
33903487 private topologicalLayers ( toolWires : Map < string , Wire [ ] > ) : string [ ] [ ] {
0 commit comments