@@ -8,6 +8,16 @@ import { logApiError } from "../utils/error-logger.js";
88import { ChatEntry } from "./llm-agent.js" ;
99import { Variable } from "./prompt-variables.js" ;
1010
11+ /**
12+ * Context for tracking CALL recursion depth and duplicate prevention
13+ */
14+ interface CallContext {
15+ /** Current recursion depth */
16+ depth : number ;
17+ /** Set of already-executed call signatures (toolName + serialized arguments) */
18+ executedCalls : Set < string > ;
19+ }
20+
1121/**
1222 * Dependencies required by HookManager for hook execution and state management
1323 */
@@ -38,6 +48,8 @@ export interface HookManagerDependencies {
3848 setTokenCounter ( counter : TokenCounter ) : void ;
3949 /** Set LLM client */
4050 setLLMClient ( client : LLMClient ) : void ;
51+ /** Execute a tool by name with parameters (for CALL commands) */
52+ executeToolByName ?( toolName : string , parameters : Record < string , any > ) : Promise < { success : boolean ; output ?: string ; error ?: string ; hookCommands ?: any [ ] } > ;
4153}
4254
4355/**
@@ -374,6 +386,32 @@ export class HookManager {
374386 const hasBackendChange = commands . backend && commands . baseUrl && commands . apiKeyEnvVar ;
375387 const hasModelChange = commands . model ;
376388
389+ // Apply immediate (non-conditional) commands right away
390+ applyEnvVariables ( commands . env ) ;
391+ for ( const { name, value} of commands . promptVars ) {
392+ Variable . set ( name , value ) ;
393+ }
394+ if ( commands . system ) {
395+ this . deps . chatHistory . push ( {
396+ type : "system" ,
397+ content : commands . system ,
398+ timestamp : new Date ( ) ,
399+ } ) ;
400+ }
401+
402+ // Check for CONDITIONAL commands without any CONDITION - this is an error
403+ if ( commands . conditionalResults && ! hasBackendChange && ! hasModelChange ) {
404+ const errorMsg = "Hook error: CONDITIONAL commands present but no CONDITION BACKEND or CONDITION MODEL specified. Conditional commands ignored." ;
405+ console . warn ( errorMsg ) ;
406+ this . deps . chatHistory . push ( {
407+ type : "system" ,
408+ content : errorMsg ,
409+ timestamp : new Date ( ) ,
410+ } ) ;
411+ // Don't return false - allow processing to continue, just skip the conditional commands
412+ }
413+
414+ // If there's a backend/model change, test it and apply conditional commands on success
377415 if ( hasBackendChange ) {
378416 const testResult = await this . testBackendModelChange (
379417 commands . backend ! ,
@@ -395,11 +433,19 @@ export class HookManager {
395433 return false ;
396434 }
397435
398- applyEnvVariables ( commands . env ) ;
399-
400- // Apply prompt variables (SET, SET_FILE, SET_TEMP_FILE commands)
401- for ( const { name, value} of commands . promptVars ) {
402- Variable . set ( name , value ) ;
436+ // Apply conditional commands after successful test
437+ if ( commands . conditionalResults ) {
438+ applyEnvVariables ( commands . conditionalResults . env ) ;
439+ for ( const { name, value} of commands . conditionalResults . promptVars ) {
440+ Variable . set ( name , value ) ;
441+ }
442+ if ( commands . conditionalResults . system ) {
443+ this . deps . chatHistory . push ( {
444+ type : "system" ,
445+ content : commands . conditionalResults . system ,
446+ timestamp : new Date ( ) ,
447+ } ) ;
448+ }
403449 }
404450
405451 const parts = [ ] ;
@@ -430,11 +476,19 @@ export class HookManager {
430476 return false ;
431477 }
432478
433- applyEnvVariables ( commands . env ) ;
434-
435- // Apply prompt variables (SET, SET_FILE, SET_TEMP_FILE commands)
436- for ( const { name, value} of commands . promptVars ) {
437- Variable . set ( name , value ) ;
479+ // Apply conditional commands after successful test
480+ if ( commands . conditionalResults ) {
481+ applyEnvVariables ( commands . conditionalResults . env ) ;
482+ for ( const { name, value} of commands . conditionalResults . promptVars ) {
483+ Variable . set ( name , value ) ;
484+ }
485+ if ( commands . conditionalResults . system ) {
486+ this . deps . chatHistory . push ( {
487+ type : "system" ,
488+ content : commands . conditionalResults . system ,
489+ timestamp : new Date ( ) ,
490+ } ) ;
491+ }
438492 }
439493
440494 const successMsg = `Model changed to "${ commands . model } "` ;
@@ -445,20 +499,21 @@ export class HookManager {
445499 } ) ;
446500
447501 this . deps . emit ( 'modelChange' , { model : commands . model } ) ;
448- } else {
449- applyEnvVariables ( commands . env ) ;
502+ }
503+ // If no backend/model change, conditional commands are ignored (there's no condition to satisfy)
450504
451- // Apply prompt variables (SET, SET_FILE, SET_TEMP_FILE commands)
452- for ( const { name, value} of commands . promptVars ) {
453- Variable . set ( name , value ) ;
454- }
505+ // Execute CALL commands after all other processing (fire-and-forget)
506+ // Execute immediate CALLs
507+ if ( commands . calls . length > 0 ) {
508+ this . executeCalls ( commands . calls ) . catch ( error => {
509+ console . error ( "Error executing immediate CALL commands:" , error ) ;
510+ } ) ;
455511 }
456512
457- if ( commands . system ) {
458- this . deps . chatHistory . push ( {
459- type : "system" ,
460- content : commands . system ,
461- timestamp : new Date ( ) ,
513+ // Execute conditional CALLs only if backend/model test succeeded
514+ if ( commands . conditionalResults && commands . conditionalResults . calls . length > 0 && ( hasBackendChange || hasModelChange ) ) {
515+ this . executeCalls ( commands . conditionalResults . calls ) . catch ( error => {
516+ console . error ( "Error executing conditional CALL commands:" , error ) ;
462517 } ) ;
463518 }
464519
@@ -692,4 +747,115 @@ export class HookManager {
692747 return true ;
693748 } ) ;
694749 }
750+
751+ /**
752+ * Execute CALL commands asynchronously with recursion depth and duplicate tracking
753+ * Fire-and-forget execution that processes hooks from called tools
754+ *
755+ * @param calls Array of CALL command strings
756+ * @param context Call context for tracking recursion and duplicates
757+ */
758+ private async executeCalls ( calls : string [ ] , context : CallContext = { depth : 0 , executedCalls : new Set ( ) } ) : Promise < void > {
759+ // Maximum recursion depth is 5
760+ const MAX_DEPTH = 5 ;
761+
762+ if ( context . depth >= MAX_DEPTH ) {
763+ console . warn ( `CALL recursion depth limit (${ MAX_DEPTH } ) reached, skipping remaining calls` ) ;
764+ return ;
765+ }
766+
767+ // Check if executeToolByName is available
768+ if ( ! this . deps . executeToolByName ) {
769+ console . warn ( "CALL commands require executeToolByName dependency, skipping calls" ) ;
770+ return ;
771+ }
772+
773+ for ( const callSpec of calls ) {
774+ // Parse "toolName arg1=val1 arg2=val2"
775+ const parts = callSpec . trim ( ) . split ( / \s + / ) ;
776+ if ( parts . length === 0 ) {
777+ continue ;
778+ }
779+
780+ const toolName = parts [ 0 ] ;
781+ const parameters : Record < string , any > = { } ;
782+
783+ // Parse parameters
784+ for ( let i = 1 ; i < parts . length ; i ++ ) {
785+ const match = parts [ i ] . match ( / ^ ( [ ^ = ] + ) = ( .* ) $ / ) ;
786+ if ( match ) {
787+ const [ , key , value ] = match ;
788+ // Try to parse as JSON, fall back to string
789+ try {
790+ parameters [ key ] = JSON . parse ( value ) ;
791+ } catch {
792+ parameters [ key ] = value ;
793+ }
794+ }
795+ }
796+
797+ // Create signature for duplicate detection
798+ const signature = `${ toolName } :${ JSON . stringify ( parameters ) } ` ;
799+ if ( context . executedCalls . has ( signature ) ) {
800+ console . warn ( `Skipping duplicate CALL: ${ signature } ` ) ;
801+ continue ;
802+ }
803+
804+ // Mark as executed
805+ context . executedCalls . add ( signature ) ;
806+
807+ // Execute tool asynchronously (fire-and-forget)
808+ this . executeCallAsync ( toolName , parameters , context ) . catch ( error => {
809+ console . error ( `Error executing CALL ${ toolName } :` , error ) ;
810+ } ) ;
811+ }
812+ }
813+
814+ /**
815+ * Execute a single CALL asynchronously with hook processing
816+ * Runs tool hooks which may generate more CALL commands
817+ *
818+ * @param toolName Tool to execute
819+ * @param parameters Tool parameters
820+ * @param context Call context for tracking recursion
821+ */
822+ private async executeCallAsync (
823+ toolName : string ,
824+ parameters : Record < string , any > ,
825+ context : CallContext
826+ ) : Promise < void > {
827+ if ( ! this . deps . executeToolByName ) {
828+ return ;
829+ }
830+
831+ try {
832+ // Execute the tool
833+ const result = await this . deps . executeToolByName ( toolName , parameters ) ;
834+
835+ // Process any hook commands that were generated during tool execution
836+ if ( result . hookCommands && result . hookCommands . length > 0 ) {
837+ const hookResults = applyHookCommands ( result . hookCommands ) ;
838+
839+ // Extract CALL commands from hook results (both immediate and conditional)
840+ const recursiveCalls : string [ ] = [ ...hookResults . calls ] ;
841+
842+ // Add conditional calls if present (they would have been validated by the tool's hooks)
843+ if ( hookResults . conditionalResults && hookResults . conditionalResults . calls . length > 0 ) {
844+ recursiveCalls . push ( ...hookResults . conditionalResults . calls ) ;
845+ }
846+
847+ // Recursively execute CALL commands with incremented depth
848+ if ( recursiveCalls . length > 0 ) {
849+ const nestedContext : CallContext = {
850+ depth : context . depth + 1 ,
851+ executedCalls : context . executedCalls , // Share the same set to prevent duplicates across entire chain
852+ } ;
853+ await this . executeCalls ( recursiveCalls , nestedContext ) ;
854+ }
855+ }
856+
857+ } catch ( error ) {
858+ console . error ( `Error in executeCallAsync for ${ toolName } :` , error ) ;
859+ }
860+ }
695861}
0 commit comments